autool-dispatcher/apk_cloud/us_download_worker.py
2026-06-17 19:50:39 +08:00

718 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
美国下载PC定时脚本
每 N 分钟从 MinIO 拉取下载任务列表,批量下载 → 导出APK → 上传到 MinIO
并写入下载结果供中控读取。下载失败的包记录详细原因,汇总为报告定期上传。
多设备下载策略:
- 第一次在模拟器A默认 Google Play上尝试下载
- 如果失败自动回退到模拟器BAurora Store再次尝试
- 如果仍然失败,最终回退到 APKPure HTTP 直连下载
用法:
python us_download_worker.py
python us_download_worker.py --export-dir D:\\mumu_apk_export --interval 600
python us_download_worker.py --once
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from typing import Any, Dict, List, Optional
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from config import (
APK_EMULATOR_CLEANUP_ENABLED,
APK_US_DEVICE_A_SERIAL,
APK_US_DEVICE_A_VM_INDEX,
APK_US_DEVICE_B_SERIAL,
APK_US_DEVICE_B_VM_INDEX,
APK_US_EXPORT_DIR,
MUMU_MANAGER_PATH,
)
from apk_cloud.storage import MinioStorage
from apk_cloud.android_google_play_downloader import (
GooglePlayDownloader,
STORE_GOOGLE_PLAY,
STORE_AURORA,
)
from apk_cloud.apkpure_downloader import download_from_apkpure, DOWNLOAD_DELAY_SECONDS
logger = logging.getLogger("us_download_worker")
_DEFAULT_POLL_SECONDS = 60
_EMULATOR_CLEANUP_INTERVAL = 3600
_FAILURE_REPORT_KEY = "download-reports/failures.json"
_HISTORY_FILE = "download_history.json"
_MAX_PIPELINE_WORKERS = 8
_APKPURE_MAX_CONCURRENT = 3
_device_a_lock = threading.Lock()
_device_b_lock = threading.Lock()
_apkpure_semaphore = threading.Semaphore(_APKPURE_MAX_CONCURRENT)
_history_lock = threading.Lock()
US_DEVICE_A_SERIAL = APK_US_DEVICE_A_SERIAL
US_DEVICE_B_SERIAL = APK_US_DEVICE_B_SERIAL
# 设备串号 → MuMu VM 编号
_US_SERIAL_VM_MAP = {
US_DEVICE_A_SERIAL: APK_US_DEVICE_A_VM_INDEX,
US_DEVICE_B_SERIAL: APK_US_DEVICE_B_VM_INDEX,
}
_US_EMULATOR_RESTART_LOCK = threading.Lock()
_US_EMULATOR_RESTART_COOLDOWN: Dict[str, float] = {}
def _is_adb_device_offline_error(error_text: str) -> bool:
if not error_text:
return False
lowered = error_text.lower()
return "device" in lowered and "not found" in lowered
def _restart_emulator_if_needed(serial: str, reason: str) -> bool:
vm_index = _US_SERIAL_VM_MAP.get(serial)
if not vm_index:
return False
if not _is_adb_device_offline_error(reason):
return False
now = time.time()
with _US_EMULATOR_RESTART_LOCK:
last = _US_EMULATOR_RESTART_COOLDOWN.get(serial, 0)
if now - last < 120:
logger.info("[Emulator] %s 在冷却期内(%.0fs前刚重启),跳过本次重启",
serial, now - last)
return False
_US_EMULATOR_RESTART_COOLDOWN[serial] = now
logger.info("[Emulator] 检测到 ADB 断链 (%s),正在重启模拟器 VM-%d ...", serial, vm_index)
manager = MUMU_MANAGER_PATH.strip()
if not os.path.isfile(manager):
logger.warning("[Emulator] MuMuManager.exe 不存在: %s", manager)
return False
try:
subprocess.run(
[manager, "control", "-v", str(vm_index), "shutdown"],
capture_output=True, timeout=30,
)
time.sleep(10)
subprocess.run(
[manager, "control", "-v", str(vm_index), "restart"],
capture_output=True, timeout=30,
)
time.sleep(30)
subprocess.run(["adb", "disconnect", serial.split(":")[0]],
capture_output=True, timeout=10)
time.sleep(5)
subprocess.run(["adb", "connect", serial],
capture_output=True, timeout=15)
time.sleep(10)
logger.info("[Emulator] VM-%d 重启完成ADB 已重连", vm_index)
return True
except subprocess.TimeoutExpired as exc:
logger.warning("[Emulator] VM-%d 重启命令超时: %s", vm_index, exc)
return False
except Exception as exc:
logger.warning("[Emulator] VM-%d 重启失败: %s", vm_index, exc)
return False
# ── Download History ────────────────────────────────────────────────
def _load_history(export_dir: str) -> Dict[str, Dict[str, str]]:
path = os.path.join(export_dir, _HISTORY_FILE)
if not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except (json.JSONDecodeError, IOError):
return {}
def _save_history(export_dir: str, history: Dict[str, Dict[str, str]]) -> None:
path = os.path.join(export_dir, _HISTORY_FILE)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(history, f, ensure_ascii=False, indent=2)
os.replace(tmp, path)
def _is_history_fresh(history: Dict[str, Dict[str, str]],
package_name: str, last_updated: str) -> bool:
entry = history.get(package_name)
if not entry:
return False
hist_updated = str(entry.get("last_updated") or "")
hist_date = str(entry.get("download_date") or "")
if not hist_date:
return False
if hist_updated != last_updated:
return False
return True
def _record_history(history: Dict[str, Dict[str, str]],
package_name: str, last_updated: str,
download_date: str = "", version_name: str = "") -> None:
history[package_name] = {
"last_updated": last_updated,
"download_date": download_date or datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"version_name": version_name or "",
}
def download_and_export(package_name: str, export_dir: str,
downloader: GooglePlayDownloader) -> tuple:
"""返回 (result_dict_or_None, failure_reason_or_None)。"""
try:
if downloader.is_installed(package_name):
logger.info("Already installed, skip download: %s", package_name)
return _export_only(package_name, export_dir, downloader), None
logger.info("Downloading: %s (device=%s)", package_name,
downloader._serial or "default")
success, message = downloader.start(package_name)
if not success:
reason = str(message or "unknown download error").strip()
logger.warning("Download failed: %s reason=%s", package_name, reason)
return None, reason
logger.info("Download succeeded: %s", package_name)
result = _export_only(package_name, export_dir, downloader)
if result is None:
return None, "export failed after download"
return result, None
finally:
# 每次下载任务完成后(无论成功失败)重置设备状态
# 清理商店页面、系统弹窗等残留,确保不影响下一个包的下载
try:
downloader.reset_state()
except Exception as exc:
logger.warning("reset_state failed: %s", exc)
def download_with_fallback(package_name: str, export_dir: str) -> tuple:
"""多设备下载 pipeline先尝试模拟器A (Google Play)失败则回退模拟器B (Aurora Store)
最终回退到 APKPure HTTP 直连下载。
各阶段通过 device lock 协调并发:
- 一个任务释放 Device A → 下一个任务立即抢占 Device A
- Device A / Device B / APKPure 可同时处理互不干扰
返回 (result_dict_or_None, failure_reason_or_None, source_details_dict)
其中 result_dict 包含 source_details 记录每个来源的尝试结果。
"""
source_details = {}
# ── 第一级Google Play (Device A) ──
logger.info("[Device A / Google Play] Trying: %s", package_name)
with _device_a_lock:
device_a = GooglePlayDownloader(serial=US_DEVICE_A_SERIAL, store_type=STORE_GOOGLE_PLAY)
result, reason = download_and_export(package_name, export_dir, device_a)
if result:
source_details["us_play"] = {
"status": "success",
"error": None,
"date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
source_details["us_aurora"] = {
"status": "not_attempted",
"error": None,
"date": None,
}
source_details["apkpure"] = {
"status": "not_attempted",
"error": None,
"date": None,
}
result["source_details"] = source_details
return result, None, source_details
source_details["us_play"] = {
"status": "failed",
"error": reason,
"date": None,
}
logger.warning("[Device A] Failed (%s), falling back to Device B / Aurora Store: %s",
reason, package_name)
if _restart_emulator_if_needed(US_DEVICE_A_SERIAL, reason):
logger.info("[Device A] 模拟器已重启,重试一次: %s", package_name)
with _device_a_lock:
device_a = GooglePlayDownloader(serial=US_DEVICE_A_SERIAL, store_type=STORE_GOOGLE_PLAY)
result, reason = download_and_export(package_name, export_dir, device_a)
if result:
source_details["us_play"] = {
"status": "success",
"error": None,
"date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
source_details["us_aurora"] = {"status": "not_attempted", "error": None, "date": None}
source_details["apkpure"] = {"status": "not_attempted", "error": None, "date": None}
result["source_details"] = source_details
return result, None, source_details
# ── 第二级Aurora Store (Device B) ──
with _device_b_lock:
device_b = GooglePlayDownloader(serial=US_DEVICE_B_SERIAL, store_type=STORE_AURORA)
result2, reason2 = download_and_export(package_name, export_dir, device_b)
if result2:
source_details["us_aurora"] = {
"status": "success",
"error": None,
"date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
source_details["apkpure"] = {
"status": "not_attempted",
"error": None,
"date": None,
}
result2["source_details"] = source_details
return result2, None, source_details
source_details["us_aurora"] = {
"status": "failed",
"error": reason2,
"date": None,
}
logger.warning(
"[Device A+B] Both failed (A=%s, B=%s), falling back to APKPure: %s",
reason, reason2, package_name,
)
if _restart_emulator_if_needed(US_DEVICE_B_SERIAL, reason2):
logger.info("[Device B] 模拟器已重启,重试一次: %s", package_name)
with _device_b_lock:
device_b = GooglePlayDownloader(serial=US_DEVICE_B_SERIAL, store_type=STORE_AURORA)
result2, reason2 = download_and_export(package_name, export_dir, device_b)
if result2:
source_details["us_aurora"] = {
"status": "success",
"error": None,
"date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
source_details["apkpure"] = {"status": "not_attempted", "error": None, "date": None}
result2["source_details"] = source_details
return result2, None, source_details
# ── 第三级最终回退APKPure HTTP 直连下载 ──
with _apkpure_semaphore:
result3, reason3 = download_from_apkpure(package_name, export_dir)
if result3:
source_details["apkpure"] = {
"status": "success",
"error": None,
"date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
result3["source_details"] = source_details
return result3, None, source_details
source_details["apkpure"] = {
"status": "failed",
"error": reason3,
"date": None,
}
return (
None,
f"all sources failed: Play={reason}, Aurora={reason2}, APKPure={reason3}",
source_details,
)
def _export_only(package_name: str, export_dir: str,
downloader: GooglePlayDownloader,
uninstall_after: bool = True) -> Optional[Dict[str, Any]]:
version = downloader.get_apk_version(package_name)
pkg_export_dir = os.path.join(export_dir, package_name)
for old_file in os.listdir(pkg_export_dir) if os.path.isdir(pkg_export_dir) else []:
if old_file.endswith(('.apk', '.xapk', '.apkm')):
try:
os.remove(os.path.join(pkg_export_dir, old_file))
except OSError:
pass
os.makedirs(pkg_export_dir, exist_ok=True)
apk_files = downloader.export_apk(package_name, pkg_export_dir)
if not apk_files:
logger.warning("APK export empty: %s", package_name)
return None
if uninstall_after and APK_EMULATOR_CLEANUP_ENABLED:
try:
downloader._adb.run(
["shell", "am", "force-stop", package_name],
check=False, timeout=10,
)
except Exception:
pass
try:
downloader._adb.run(
["shell", "pm", "clear", package_name],
check=False, timeout=15,
)
except Exception:
pass
try:
downloader._adb.run(
["shell", "pm", "uninstall", "--user", "0", package_name],
check=True, timeout=30,
)
logger.info("Uninstalled after export: %s", package_name)
except Exception as exc:
logger.warning("Uninstall failed: %s: %s", package_name, exc)
try:
downloader._adb.run(
["shell", "pm", "uninstall", package_name],
check=False, timeout=15,
)
except Exception:
pass
download_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
file_infos = []
for fp in apk_files:
size = os.path.getsize(fp) if os.path.isfile(fp) else 0
file_infos.append({
"filename": os.path.basename(fp),
"size": size,
})
return {
"package_name": package_name,
"download_date": download_date,
"version_name": version,
"files": file_infos,
"local_dir": export_dir,
}
def _process_single_task(task: Dict[str, str], export_dir: str,
storage: MinioStorage,
history: Dict[str, Dict[str, str]]) -> Dict[str, Any]:
"""处理单个下载任务pipeline 下载 → 上传 MinIO → 记录结果。
返回 {"success": bool, "failures": [...]} 供 run_cycle 聚合统计。
"""
package_name = str(task.get("package_name") or "").strip()
last_updated = str(task.get("last_updated") or "").strip()
if not package_name:
return {"success": False, "failures": []}
try:
result, fail_reason, source_details = download_with_fallback(package_name, export_dir)
except Exception as exc:
logger.exception("download_with_fallback panicked: %s", package_name)
fail_info = {
"package_name": package_name,
"reason": f"pipeline error: {exc}",
"failed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
fallback = {
"status": "failed",
"package_name": package_name,
"download_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"reason": str(exc),
"source_details": {},
}
try:
storage.write_download_result(package_name, fallback)
except Exception:
pass
with _history_lock:
_record_history(history, package_name, last_updated, download_date="none", version_name="")
_save_history(export_dir, history)
return {"success": False, "failures": [fail_info]}
if result:
result["status"] = "ok"
pkg_dir = os.path.join(export_dir, package_name)
try:
version_code = result.get("version_name", "").replace(".", "_") or "v0"
manifest = storage.upload_apk(
package_name, export_dir,
download_date=result["download_date"][:10],
version_code=version_code,
)
result["uploaded_files"] = manifest.get("files", [])
result["download_date"] = manifest.get("download_date", result.get("download_date", ""))
result["version_code"] = manifest.get("version_code", "")
storage.cleanup_old_versions(package_name)
with _history_lock:
_record_history(history, package_name, last_updated,
download_date=result.get("download_date", ""),
version_name=result.get("version_name", ""))
_save_history(export_dir, history)
except Exception as exc:
logger.error("Upload failed for %s: %s", package_name, exc)
result["status"] = "download_ok_upload_failed"
result["upload_error"] = str(exc)
try:
storage.write_download_result(package_name, result)
except Exception:
pass
return {
"success": False,
"failures": [{
"package_name": package_name,
"reason": f"upload failed: {exc}",
"failed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}],
}
finally:
if os.path.isdir(pkg_dir):
shutil.rmtree(pkg_dir, ignore_errors=True)
logger.info("[Upload] 已清理本地文件: %s", pkg_dir)
storage.write_download_result(package_name, result)
return {"success": True, "failures": []}
else:
reason = fail_reason or "download_or_export_failed"
result = {
"status": "failed",
"package_name": package_name,
"download_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"reason": reason,
"source_details": source_details,
}
storage.write_download_result(package_name, result)
with _history_lock:
_record_history(history, package_name, last_updated, download_date="none", version_name="")
_save_history(export_dir, history)
return {
"success": False,
"failures": [{
"package_name": package_name,
"reason": reason,
"failed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}],
}
def run_cycle(storage: MinioStorage, export_dir: str,
history: Dict[str, Dict[str, str]] = None) -> Dict[str, Any]:
history = history if history is not None else {}
tasks = storage.pull_download_queue()
if not tasks:
logger.info("No tasks in download queue, sleeping")
return {"processed": 0, "uploaded": 0, "failures": []}
original_count = len(tasks)
filtered = []
skipped = 0
for task in tasks:
package_name = str(task.get("package_name") or "").strip()
last_updated = str(task.get("last_updated") or "").strip()
if not package_name or ' ' in package_name or '&&' in package_name:
logger.warning("Skipping invalid task entry: %s", task)
continue
if not task.get("force") and _is_history_fresh(history, package_name, last_updated):
skipped += 1
continue
filtered.append(task)
if skipped:
logger.info("History filter: %d/%d tasks skipped (already downloaded)",
skipped, original_count)
tasks = filtered
logger.info("%d tasks to process (max_workers=%d, priority ordered)", len(tasks), _MAX_PIPELINE_WORKERS)
processed = 0
uploaded = 0
failures: List[Dict[str, Any]] = []
if not tasks:
return {"processed": 0, "uploaded": 0, "failures": []}
max_workers = min(_MAX_PIPELINE_WORKERS, max(len(tasks), 1))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_map = {}
for task in tasks:
future = executor.submit(_process_single_task, task, export_dir, storage, history)
future_map[future] = task
for future in as_completed(future_map):
task_result = future.result()
if task_result.get("success"):
processed += 1
uploaded += 1
failures.extend(task_result.get("failures", []))
return {"processed": processed, "uploaded": uploaded, "failures": failures}
def upload_failure_report(storage: MinioStorage, failures: List[Dict[str, Any]]) -> None:
if not failures:
return
try:
existing = storage.download_json(_FAILURE_REPORT_KEY) or {}
except Exception:
existing = {}
existing.setdefault("failures", [])
existing_failures = existing.get("failures", [])
if not isinstance(existing_failures, list):
existing_failures = []
existing["failures"] = existing_failures
existing_failures.extend(failures)
existing["generated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
existing["worker"] = "us-downloader"
existing["total_failures"] = len(existing_failures)
try:
storage.upload_json(existing, _FAILURE_REPORT_KEY)
logger.info("Failure report uploaded: %d total failures", len(existing_failures))
except Exception as exc:
logger.warning("Failed to upload failure report: %s", exc)
def cleanup_emulator_third_party_apps(downloader: GooglePlayDownloader) -> int:
result = downloader._adb.run(
["shell", "pm", "list", "packages", "-3"],
check=False, timeout=30,
)
if result.returncode != 0:
return 0
packages = []
for line in (result.stdout or "").splitlines():
line = line.strip()
if line.startswith("package:"):
pkg = line.split(":", 1)[1].strip()
if pkg and pkg not in ("com.android.vending", "com.aurora.store"):
packages.append(pkg)
uninstalled = 0
for pkg in packages:
try:
r = downloader._adb.run(
["shell", "pm", "uninstall", pkg],
check=False, timeout=15,
)
if r.returncode == 0:
uninstalled += 1
logger.info("Emulator cleanup uninstalled: %s", pkg)
except Exception:
pass
return uninstalled
def main_loop(export_dir: str, poll_seconds: int = _DEFAULT_POLL_SECONDS):
storage = MinioStorage()
downloader_for_cleanup = GooglePlayDownloader(serial=US_DEVICE_B_SERIAL, store_type=STORE_AURORA)
last_emulator_cleanup = time.time()
accumulated_failures: List[Dict[str, Any]] = []
history = _load_history(export_dir)
history_count = len(history)
logger.info("US Download Worker started, poll_interval=%ds, export_dir=%s",
poll_seconds, export_dir)
logger.info("Device A (Google Play): %s, Device B (Aurora): %s",
US_DEVICE_A_SERIAL, US_DEVICE_B_SERIAL)
logger.info("MinIO: %s / %s", storage.endpoint, storage.bucket)
logger.info("History: %d previously downloaded packages", history_count)
while True:
start_time = time.time()
try:
stats = run_cycle(storage, export_dir, history)
elapsed = time.time() - start_time
logger.info("Cycle done: processed=%d uploaded=%d failed=%d elapsed=%.1fs",
stats["processed"], stats["uploaded"],
len(stats.get("failures", [])), elapsed)
failures = stats.get("failures", [])
if failures:
accumulated_failures.extend(failures)
upload_failure_report(storage, accumulated_failures)
if stats["uploaded"] > 0:
try:
limit_result = storage.enforce_storage_limit()
if limit_result.get("deleted_versions"):
logger.info(
"MinIO storage limit: %.1fGB -> %.1fGB (deleted %d versions)",
limit_result["before_bytes"] / (1024 ** 3),
limit_result["after_bytes"] / (1024 ** 3),
limit_result["deleted_versions"],
)
except Exception as exc:
logger.warning("MinIO storage limit check failed: %s", exc)
if APK_EMULATOR_CLEANUP_ENABLED and (time.time() - last_emulator_cleanup >= _EMULATOR_CLEANUP_INTERVAL):
try:
count = cleanup_emulator_third_party_apps(downloader_for_cleanup)
if count > 0:
logger.info("Emulator cleanup: uninstalled %d apps", count)
except Exception as exc:
logger.warning("Emulator cleanup failed: %s", exc)
last_emulator_cleanup = time.time()
except Exception:
logger.exception("Cycle error")
sleep_left = max(0, poll_seconds - (time.time() - start_time))
if sleep_left > 0:
logger.info("Sleeping %.0fs until next poll...", sleep_left)
time.sleep(sleep_left)
def main() -> int:
import argparse
parser = argparse.ArgumentParser(description="US Download Worker")
parser.add_argument("--export-dir", default=APK_US_EXPORT_DIR,
help=f"APK导出目录 (default: {APK_US_EXPORT_DIR})")
parser.add_argument("--interval", type=int, default=_DEFAULT_POLL_SECONDS,
help=f"轮询间隔秒数 (default: {_DEFAULT_POLL_SECONDS})")
parser.add_argument("--once", action="store_true",
help="只执行一次循环后退出")
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
export_dir = str(args.export_dir or "").strip() or APK_US_EXPORT_DIR
os.makedirs(export_dir, exist_ok=True)
if args.once:
storage = MinioStorage()
history = _load_history(export_dir)
stats = run_cycle(storage, export_dir, history)
failures = stats.get("failures", [])
if failures:
upload_failure_report(storage, failures)
print(f"done: processed={stats['processed']} uploaded={stats['uploaded']} failed={len(failures)}")
return 0
main_loop(export_dir, poll_seconds=args.interval)
return 0
if __name__ == "__main__":
raise SystemExit(main())