#!/usr/bin/env python3 """ 本地 APK 注册表 跟踪本地已缓存的 APK 信息,提供检查 APK 是否足够新的方法。 比较逻辑:download_date >= last_updated 表示此 APK 可用。 包含以下子模块: - apk_registry: 本地 APK 文件注册 (package_name -> download_date / local_dir / files) - apk_pending_download: 等待下载的包队列 - app_download_record: 下载结果跟踪 (per-package, per-country, per-source) 数据存储在 monitoring.sqlite3 中。 表 schema 由 monitoring.py 的 MonitoringRepository._initialize_schema() 管理。 """ from __future__ import annotations import json import os import sqlite3 import sys import threading import time from contextlib import contextmanager 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 MONITORING_DB_PATH, APK_LOCAL_STORAGE_DIR, APK_SMB_DIR _DEFAULT_SMB_DIR = "" def _parse_date(value: Any) -> Optional[datetime]: text = str(value or "").strip() if not text: return None for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d", "%Y/%m/%d", "%Y-%m"): try: return datetime.strptime(text, fmt) except (ValueError, IndexError): continue return None def _last_updated_ts(value: Any) -> float: dt = _parse_date(value) return dt.timestamp() if dt else 0.0 def _is_apk_stale(download_date: Any, last_updated: Any) -> bool: return _last_updated_ts(download_date) < _last_updated_ts(last_updated) def _smb_path(package_name: str, smb_root: str = "") -> str: root = str(smb_root or APK_SMB_DIR or _DEFAULT_SMB_DIR).rstrip("\\") if not root: return "" return f"{root}\\{package_name}" class ApkRegistry: def __init__(self, db_path: str = "", storage_dir: str = "", smb_dir: str = ""): self.db_path = db_path or MONITORING_DB_PATH self.storage_dir = storage_dir or APK_LOCAL_STORAGE_DIR self.smb_dir = smb_dir or APK_SMB_DIR or _DEFAULT_SMB_DIR self._write_lock = threading.Lock() self._ensure_tables() @contextmanager def _connect(self): """每次操作使用独立连接,避免跨线程共享连接导致游标状态串扰。""" connection = sqlite3.connect(self.db_path, timeout=30) connection.row_factory = sqlite3.Row connection.execute("PRAGMA busy_timeout=30000") connection.execute("PRAGMA synchronous=NORMAL") connection.execute("PRAGMA journal_mode=WAL") try: yield connection connection.commit() except Exception: try: connection.rollback() except Exception: pass raise finally: connection.close() def _ensure_tables(self) -> None: """确保 apk_registry、apk_pending_download、app_download_record 表存在。 通常由 MonitoringRepository._initialize_schema() 创建, 此处做兜底确认,避免单独使用 ApkRegistry 时表不存在。 """ os.makedirs(os.path.dirname(self.db_path) or ".", exist_ok=True) with self._connect() as conn: conn.executescript( """ CREATE TABLE IF NOT EXISTS apk_registry ( package_name TEXT PRIMARY KEY, download_date TEXT NOT NULL, download_time REAL NOT NULL, local_dir TEXT, smb_dir TEXT, version_name TEXT, source TEXT, apk_files_json TEXT, updated_at REAL NOT NULL ); CREATE TABLE IF NOT EXISTS apk_pending_download ( package_name TEXT PRIMARY KEY, created_at REAL NOT NULL ); CREATE TABLE IF NOT EXISTS app_download_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, package_name TEXT NOT NULL, country_code TEXT NOT NULL DEFAULT '', app_name TEXT DEFAULT '', last_updated TEXT NOT NULL DEFAULT '', downloads INTEGER DEFAULT 0, worker_play_status TEXT NOT NULL DEFAULT 'pending', worker_play_error TEXT, worker_play_date TEXT, us_play_status TEXT NOT NULL DEFAULT 'pending', us_play_error TEXT, us_play_date TEXT, us_aurora_status TEXT NOT NULL DEFAULT 'pending', us_aurora_error TEXT, us_aurora_date TEXT, apkpure_status TEXT NOT NULL DEFAULT 'pending', apkpure_error TEXT, apkpure_date TEXT, overall_status TEXT NOT NULL DEFAULT 'pending', overall_error TEXT, apk_version_name TEXT DEFAULT '', local_apk_dir TEXT DEFAULT '', local_apk_file_count INTEGER DEFAULT 0, local_apk_is_stale INTEGER DEFAULT 1, created_at REAL NOT NULL, updated_at REAL NOT NULL, UNIQUE(package_name, country_code) ); CREATE INDEX IF NOT EXISTS idx_download_record_pkg ON app_download_record(package_name); CREATE INDEX IF NOT EXISTS idx_download_record_country ON app_download_record(country_code); CREATE INDEX IF NOT EXISTS idx_download_record_overall ON app_download_record(overall_status); CREATE INDEX IF NOT EXISTS idx_download_record_stale ON app_download_record(local_apk_is_stale); """ ) # 为已存在的表安全添加 apkpure 列(列已存在时忽略错误) for col_def in ( "apkpure_status TEXT NOT NULL DEFAULT 'pending'", "apkpure_error TEXT", "apkpure_date TEXT", ): try: conn.execute( f"ALTER TABLE app_download_record ADD COLUMN {col_def}" ) except sqlite3.OperationalError: pass # 列已存在,忽略 # ── query ────────────────────────────────────────────────────── def get(self, package_name: str) -> Optional[Dict[str, Any]]: with self._connect() as conn: row = conn.execute( "SELECT * FROM apk_registry WHERE package_name = ?", (package_name,), ).fetchone() if not row: return None return self._row_to_entry(row) def get_local_apk_dir(self, package_name: str) -> Optional[str]: entry = self.get(package_name) if not entry: return None local_dir = entry.get("local_dir", "") if local_dir and os.path.isdir(local_dir): return local_dir pkg_dir = os.path.join(self.storage_dir, package_name) if os.path.isdir(pkg_dir): return pkg_dir return None def get_smb_apk_dir(self, package_name: str) -> Optional[str]: """返回 Windows Worker 可访问的 SMB 路径。""" entry = self.get(package_name) if not entry: return None smb = entry.get("smb_dir", "") if smb: return smb return _smb_path(package_name, self.smb_dir) or None def is_fresh_enough(self, package_name: str, last_updated: Any) -> bool: with self._connect() as conn: row = conn.execute( "SELECT download_date FROM apk_registry WHERE package_name = ?", (package_name,), ).fetchone() if not row: return False download_date = row["download_date"] or "" if not download_date: return False download_ts = _last_updated_ts(download_date) last_ts = _last_updated_ts(last_updated) return download_ts >= last_ts def list_all_packages(self) -> List[str]: with self._connect() as conn: rows = conn.execute( "SELECT package_name FROM apk_registry ORDER BY package_name" ).fetchall() return [row["package_name"] for row in rows] def get_download_time(self, package_name: str) -> float: with self._connect() as conn: row = conn.execute( "SELECT download_time FROM apk_registry WHERE package_name = ?", (package_name,), ).fetchone() if not row: return 0.0 return float(row["download_time"] or 0.0) # ── pending download tracking ────────────────────────────────── def mark_pending_download(self, package_name: str) -> None: with self._write_lock, self._connect() as conn: conn.execute( "INSERT OR IGNORE INTO apk_pending_download (package_name, created_at) VALUES (?, ?)", (package_name, time.time()), ) def clear_pending_download(self, package_name: str) -> None: with self._write_lock, self._connect() as conn: conn.execute( "DELETE FROM apk_pending_download WHERE package_name = ?", (package_name,), ) def is_pending_download(self, package_name: str) -> bool: with self._connect() as conn: row = conn.execute( "SELECT 1 FROM apk_pending_download WHERE package_name = ?", (package_name,), ).fetchone() return bool(row) def get_pending_download_packages(self) -> List[str]: with self._connect() as conn: rows = conn.execute( "SELECT package_name FROM apk_pending_download" ).fetchall() return [row["package_name"] for row in rows] def get_pending_that_are_available(self) -> List[str]: """返回已标记为 pending 且在 apk_registry 中有记录(有 local_dir)的包名。""" with self._connect() as conn: rows = conn.execute( """ SELECT p.package_name FROM apk_pending_download p JOIN apk_registry r ON p.package_name = r.package_name WHERE r.local_dir IS NOT NULL AND r.local_dir != '' """, ).fetchall() return [row["package_name"] for row in rows] def get_all_pending_downloads(self) -> List[str]: with self._connect() as conn: rows = conn.execute( "SELECT DISTINCT package_name FROM apk_pending_download" ).fetchall() return [row["package_name"] for row in rows] def has_failed_with_same_version(self, package_name: str, last_updated: str) -> bool: with self._connect() as conn: row = conn.execute( """ SELECT 1 FROM app_download_record WHERE package_name = ? AND last_updated = ? AND (worker_play_status = 'failed' OR us_play_status = 'failed' OR us_aurora_status = 'failed' OR apkpure_status = 'failed') LIMIT 1 """, (package_name, last_updated), ).fetchone() return bool(row) def verify_apk_versions(self, package_name: str) -> bool: pkg_dir = os.path.join(self.storage_dir, package_name) base = os.path.join(pkg_dir, "base.apk") if not os.path.isfile(base): return True try: import subprocess, re from log_manager import logger out = subprocess.check_output( ["aapt", "dump", "badging", base], stderr=subprocess.DEVNULL, timeout=15, ).decode() m = re.search(r"versionCode='(\d+)'", out) base_vc = int(m.group(1)) if m else None if base_vc is None: return True except Exception: return True for f in os.listdir(pkg_dir): if not f.startswith("split_") or not f.endswith(".apk"): continue fp = os.path.join(pkg_dir, f) try: out = subprocess.check_output( ["aapt", "dump", "badging", fp], stderr=subprocess.DEVNULL, timeout=15, ).decode() m = re.search(r"versionCode='(\d+)'", out) split_vc = int(m.group(1)) if m else None if split_vc is not None and split_vc != base_vc: logger.warning("[APK] 版本不匹配 %s: base=%d %s=%d", package_name, base_vc, f, split_vc) return False except Exception: pass return True # ── update ───────────────────────────────────────────────────── def mark_available(self, package_name: str, download_date: str, local_dir: str = "", version_name: str = "", apk_files: Optional[List[Dict[str, Any]]] = None, source: str = "") -> None: apk_files_json = json.dumps(apk_files or [], ensure_ascii=False) smb_dir = _smb_path(package_name, self.smb_dir) now = time.time() with self._write_lock, self._connect() as conn: conn.execute( """ INSERT INTO apk_registry ( package_name, download_date, download_time, local_dir, smb_dir, version_name, source, apk_files_json, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(package_name) DO UPDATE SET download_date = excluded.download_date, download_time = excluded.download_time, local_dir = excluded.local_dir, smb_dir = excluded.smb_dir, version_name = excluded.version_name, source = excluded.source, apk_files_json = excluded.apk_files_json, updated_at = excluded.updated_at """, (package_name, download_date, now, local_dir, smb_dir, version_name, source or "", apk_files_json, now), ) # 一旦本地有 APK,清除 pending 状态 conn.execute( "DELETE FROM apk_pending_download WHERE package_name = ?", (package_name,), ) # 同步刷新 app_download_record 的 stale 标志 self._sync_stale_for_package(conn, package_name, download_date) def mark_unavailable(self, package_name: str) -> None: with self._write_lock, self._connect() as conn: conn.execute( "DELETE FROM apk_registry WHERE package_name = ?", (package_name,), ) conn.execute( "UPDATE app_download_record SET local_apk_is_stale = 1, " "local_apk_file_count = 0, updated_at = ? " "WHERE package_name = ?", (time.time(), package_name), ) def _sync_stale_for_package(self, conn: sqlite3.Connection, package_name: str, download_date: str) -> None: rows = conn.execute( "SELECT country_code, last_updated FROM app_download_record " "WHERE package_name = ?", (package_name,), ).fetchall() now = time.time() for row in rows: is_stale = 1 if _is_apk_stale(download_date, row["last_updated"]) else 0 conn.execute( """ UPDATE app_download_record SET local_apk_is_stale = ?, local_apk_file_count = ( SELECT COUNT(*) FROM json_each( COALESCE( (SELECT apk_files_json FROM apk_registry WHERE package_name = app_download_record.package_name), '[]' ) ) ), local_apk_dir = ?, apk_version_name = COALESCE( (SELECT version_name FROM apk_registry WHERE package_name = app_download_record.package_name), apk_version_name ), updated_at = ? WHERE package_name = ? AND country_code = ? """, (is_stale, self.storage_dir, now, package_name, row["country_code"]), ) def import_from_download_result(self, package_name: str, result: Dict[str, Any], local_dir: str = "", source: str = "minio") -> None: self.mark_available( package_name=package_name, download_date=result.get("download_date", ""), local_dir=local_dir or self.storage_dir, version_name=result.get("version_name", ""), apk_files=result.get("files", result.get("uploaded_files")), source=source, ) # ── stale detection ──────────────────────────────────────────── def get_stale_packages(self, catalog_last_updated: Dict[str, str]) -> List[str]: stale = [] for package_name, last_updated in catalog_last_updated.items(): if not self.is_fresh_enough(package_name, last_updated): stale.append(package_name) return stale # ── download record management ───────────────────────────────── def ensure_download_record(self, package_name: str, country_code: str, app_name: str = "", last_updated: str = "", downloads: int = 0) -> None: """懒初始化:从新目录任务信息同步一条下载记录。 仅在 Redis 任务队列中需要处理此包时由中控调用。 如果记录已存在则更新 catalog 字段(last_updated / downloads)。 """ package_name = str(package_name or "").strip() country_code = str(country_code or "").strip() if not package_name: return now = time.time() with self._write_lock, self._connect() as conn: conn.execute( """ INSERT INTO app_download_record ( package_name, country_code, app_name, last_updated, downloads, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(package_name, country_code) DO UPDATE SET app_name = COALESCE(excluded.app_name, app_download_record.app_name), last_updated = excluded.last_updated, downloads = excluded.downloads, updated_at = excluded.updated_at """, (package_name, country_code, app_name or "", last_updated or "", int(downloads or 0), now, now), ) def update_source_result(self, package_name: str, country_code: str, source: str, status: str, error: str = "", download_date: str = "") -> None: """更新单个下载源 (worker_play / us_play / us_aurora / apkpure) 的结果。""" package_name = str(package_name or "").strip() country_code = str(country_code or "").strip() source = str(source or "").strip().lower() status = str(status or "pending").strip() now = time.time() source_col_map = { "worker_play": ("worker_play_status", "worker_play_error", "worker_play_date"), "us_play": ("us_play_status", "us_play_error", "us_play_date"), "us_aurora": ("us_aurora_status", "us_aurora_error", "us_aurora_date"), "apkpure": ("apkpure_status", "apkpure_error", "apkpure_date"), } if source not in source_col_map: return status_col, error_col, date_col = source_col_map[source] with self._write_lock, self._connect() as conn: conn.execute( f""" UPDATE app_download_record SET {status_col} = ?, {error_col} = CASE WHEN ? = 'failed' THEN ? ELSE NULL END, {date_col} = CASE WHEN ? = 'success' THEN ? ELSE NULL END, updated_at = ? WHERE package_name = ? AND country_code = ? """, (status, status, error or "", status, download_date or "", now, package_name, country_code), ) self._recompute_overall(conn, package_name, country_code) def _recompute_overall(self, conn: sqlite3.Connection, package_name: str, country_code: str) -> None: """根据四个来源的状态计算 overall_status 和 overall_error。""" row = conn.execute( """ SELECT worker_play_status, us_play_status, us_aurora_status, apkpure_status, worker_play_error, us_play_error, us_aurora_error, apkpure_error FROM app_download_record WHERE package_name = ? AND country_code = ? """, (package_name, country_code), ).fetchone() if not row: return wp_s, up_s, ua_s, ap_s = ( row["worker_play_status"] or "pending", row["us_play_status"] or "pending", row["us_aurora_status"] or "pending", row["apkpure_status"] or "pending", ) successes = sum(1 for s in (wp_s, up_s, ua_s, ap_s) if s == "success") failures = [(col, e) for s, e, col in ( (wp_s, row["worker_play_error"], "worker_play"), (up_s, row["us_play_error"], "us_play"), (ua_s, row["us_aurora_error"], "us_aurora"), (ap_s, row["apkpure_error"], "apkpure"), ) if s == "failed"] if successes >= 1: overall_status = "success" overall_error = "" elif failures: overall_status = "all_failed" error_parts = [] for src, err in failures: label = { "worker_play": "Worker Play", "us_play": "US Play", "us_aurora": "US Aurora", "apkpure": "APKPure", }.get(src, src) error_parts.append(f"{label}: {err or 'unknown'}") overall_error = "; ".join(error_parts) else: overall_status = "pending" overall_error = "" conn.execute( """ UPDATE app_download_record SET overall_status = ?, overall_error = ?, updated_at = ? WHERE package_name = ? AND country_code = ? """, (overall_status, overall_error, time.time(), package_name, country_code), ) def record_download_from_result(self, package_name: str, country_code: str, result: Dict[str, Any]) -> None: """从 MinIO download result 解析 source_details 写入下载记录。""" package_name = str(package_name or "").strip() country_code = str(country_code or "").strip() if not package_name: return existing = self.get_download_record(package_name, country_code) if not existing: self.ensure_download_record( package_name=package_name, country_code=country_code, app_name=result.get("app_name", ""), last_updated=result.get("last_updated", ""), downloads=int(result.get("downloads") or 0), ) source_details = result.get("source_details") if isinstance(source_details, dict): for source, detail in source_details.items(): if not isinstance(detail, dict): continue self.update_source_result( package_name=package_name, country_code=country_code, source=source, status=detail.get("status", "not_attempted"), error=detail.get("error", ""), download_date=detail.get("date", ""), ) status = result.get("status", "") if status == "ok": self._update_local_apk_info(package_name, country_code, result.get("files", []), result.get("version_name", ""), result.get("download_date", "")) self._refresh_stale_flag(package_name, country_code) def _update_local_apk_info(self, package_name: str, country_code: str, files: List[Dict[str, Any]], version_name: str = "", download_date: str = "") -> None: """更新本地 APK 文件信息和 stale 标志。""" now = time.time() with self._write_lock, self._connect() as conn: conn.execute( """ UPDATE app_download_record SET apk_version_name = ?, local_apk_file_count = ?, local_apk_dir = ?, local_apk_is_stale = ?, updated_at = ? WHERE package_name = ? AND country_code = ? """, (version_name, len(files), self.storage_dir, 1 if _is_apk_stale(download_date, self._get_last_updated(conn, package_name, country_code)) else 0, now, package_name, country_code), ) def _get_last_updated(self, conn: sqlite3.Connection, package_name: str, country_code: str) -> str: row = conn.execute( """ SELECT last_updated FROM app_download_record WHERE package_name = ? AND country_code = ? """, (package_name, country_code), ).fetchone() return row["last_updated"] if row else "" def _refresh_stale_flag(self, package_name: str, country_code: str) -> None: """重新从 apk_registry 检查并刷新 local_apk_is_stale。""" entry = self.get(package_name) download_date = entry.get("download_date", "") if entry else "" last_updated = "" with self._connect() as conn: last_updated = self._get_last_updated(conn, package_name, country_code) is_stale = 1 if _is_apk_stale(download_date, last_updated) else 0 now = time.time() with self._write_lock, self._connect() as conn: conn.execute( """ UPDATE app_download_record SET local_apk_is_stale = ?, updated_at = ? WHERE package_name = ? AND country_code = ? """, (is_stale, now, package_name, country_code), ) def get_stale_records(self) -> List[Dict[str, Any]]: """返回 local_apk_is_stale = 1 的下载记录列表。""" with self._connect() as conn: rows = conn.execute( """ SELECT package_name, country_code, app_name, last_updated, downloads, worker_play_status, us_play_status, us_aurora_status, overall_status, overall_error, apk_version_name, local_apk_dir, local_apk_file_count, local_apk_is_stale FROM app_download_record WHERE local_apk_is_stale = 1 ORDER BY downloads DESC """ ).fetchall() return [dict(row) for row in rows] def get_download_record(self, package_name: str, country_code: str = "") -> Optional[Dict[str, Any]]: """按包名和国家码查询下载记录。""" package_name = str(package_name or "").strip() country_code = str(country_code or "").strip() with self._connect() as conn: row = conn.execute( """ SELECT * FROM app_download_record WHERE package_name = ? AND country_code = ? """, (package_name, country_code), ).fetchone() return dict(row) if row else None def list_download_records(self, country_code: str = "", overall_status: str = "") -> List[Dict[str, Any]]: """列出下载记录,可按国家码和/或整体状态过滤。""" with self._connect() as conn: conditions = [] params: List[Any] = [] if country_code: conditions.append("country_code = ?") params.append(country_code) if overall_status: conditions.append("overall_status = ?") params.append(overall_status) where = "" if conditions: where = "WHERE " + " AND ".join(conditions) rows = conn.execute( f""" SELECT * FROM app_download_record {where} ORDER BY downloads DESC """, params, ).fetchall() return [dict(row) for row in rows] def cleanup_stale_local(self, package_name: str) -> bool: """删除本地过期的 APK 目录。返回是否成功删除。""" pkg_dir = os.path.join(self.storage_dir, package_name) if not os.path.isdir(pkg_dir): return True import shutil try: shutil.rmtree(pkg_dir, ignore_errors=True) self.mark_unavailable(package_name) return True except OSError: return False # ── local disk refresh (enhanced) ─────────────────────────────── def refresh_from_local_disk(self) -> int: if not os.path.isdir(self.storage_dir): return 0 found = 0 for entry_name in os.listdir(self.storage_dir): pkg_dir = os.path.join(self.storage_dir, entry_name) if not os.path.isdir(pkg_dir): continue manifest_path = os.path.join(pkg_dir, f"{entry_name}.json") if os.path.isfile(manifest_path): try: with open(manifest_path, "r", encoding="utf-8") as fh: manifest = json.load(fh) except (json.JSONDecodeError, IOError): manifest = None else: manifest = None if manifest: package_name = manifest.get("package_name", entry_name) download_date = manifest.get("download_date", "") version_name = manifest.get("version_name", "") apk_files = manifest.get("files") source = manifest.get("source", "local_disk") else: package_name = entry_name apk_files_info, earliest_ctime = self._scan_apk_files(pkg_dir) if not apk_files_info: continue download_date = datetime.fromtimestamp(earliest_ctime).strftime("%Y-%m-%d %H:%M:%S") version_name = "" apk_files = apk_files_info source = "local_disk_ctime" self.mark_available( package_name=package_name, download_date=download_date, local_dir=self.storage_dir, version_name=version_name, apk_files=apk_files, source=source, ) found += 1 return found @staticmethod def _scan_apk_files(pkg_dir: str) -> tuple: """扫描目录下的 APK 文件,返回 (文件信息列表, 最早创建时间)。 用于没有 manifest JSON 时的本地磁盘恢复。 """ apk_extensions = (".apk", ".xapk", ".apkm") file_infos: List[Dict[str, Any]] = [] earliest_ctime: Optional[float] = None for name in sorted(os.listdir(pkg_dir)): fp = os.path.join(pkg_dir, name) if not os.path.isfile(fp): continue if not name.lower().endswith(apk_extensions): continue try: st = os.stat(fp) except OSError: continue ctime = st.st_ctime if earliest_ctime is None or ctime < earliest_ctime: earliest_ctime = ctime file_infos.append({ "filename": name, "size": st.st_size, }) return file_infos, earliest_ctime or 0.0 # ── internal ─────────────────────────────────────────────────── @staticmethod def _row_to_entry(row: sqlite3.Row) -> Dict[str, Any]: """将 SQLite Row 转换为与旧 JSON 格式兼容的字典。""" apk_files_raw = row["apk_files_json"] or "[]" try: apk_files = json.loads(apk_files_raw) except (json.JSONDecodeError, TypeError): apk_files = [] return { "download_date": row["download_date"], "download_time": float(row["download_time"] or 0.0), "local_dir": row["local_dir"] or "", "smb_dir": row["smb_dir"] or "", "version_name": row["version_name"] or "", "source": row["source"] or "", "apk_files": apk_files, } def main() -> int: registry = ApkRegistry() # Test basic operations registry.mark_available( "com.test.app1", download_date="2026-05-19", local_dir="/tmp/app1", version_name="1.0", source="test", ) entry = registry.get("com.test.app1") assert entry is not None assert entry["download_date"] == "2026-05-19" assert entry["source"] == "test" assert entry["smb_dir"].startswith("\\\\") print(" [OK] mark_available + get") # Test freshness check assert registry.is_fresh_enough("com.test.app1", "2026-05-18") assert registry.is_fresh_enough("com.test.app1", "2026-05-19") assert not registry.is_fresh_enough("com.test.app1", "2026-05-20") assert not registry.is_fresh_enough("com.nonexistent", "2026-01-01") print(" [OK] is_fresh_enough") # Test SMB path smb = registry.get_smb_apk_dir("com.test.app1") assert smb is not None and "mumu_apk" in smb print(f" [OK] get_smb_apk_dir = {smb}") # Test pending download registry.mark_pending_download("com.test.need_apk") assert registry.is_pending_download("com.test.need_apk") assert not registry.is_pending_download("com.test.app1") assert "com.test.need_apk" in registry.get_pending_download_packages() print(" [OK] pending download tracking") # Test that mark_available clears pending registry.mark_available( "com.test.need_apk", download_date="2026-05-19", local_dir="/tmp/app2", ) assert not registry.is_pending_download("com.test.need_apk") print(" [OK] mark_available clears pending") # Test pending_that_are_available registry.mark_pending_download("com.test.app1") ready = registry.get_pending_that_are_available() assert "com.test.app1" in ready # already has local_dir from mark_available print(f" [OK] pending_that_are_available: {ready}") # Test stale detection catalog = { "com.test.app1": "2026-05-19", "com.test.app2": "2026-05-25", } stale = registry.get_stale_packages(catalog) assert "com.test.app2" in stale assert "com.test.app1" not in stale print(" [OK] get_stale_packages") # Test list_all_packages all_pkgs = registry.list_all_packages() assert "com.test.app1" in all_pkgs assert "com.test.need_apk" in all_pkgs print(f" [OK] list_all_packages: {all_pkgs}") # Test get_download_time dt = registry.get_download_time("com.test.app1") assert dt > 0 print(f" [OK] get_download_time: {dt}") # ── download record tests ── print("\n Download record tests ...") # Test ensure_download_record (lazy init) registry.ensure_download_record( package_name="com.test.dl", country_code="US", app_name="Test DL App", last_updated="2026-05-20", downloads=5000000, ) record = registry.get_download_record("com.test.dl", "US") assert record is not None assert record["app_name"] == "Test DL App" assert record["last_updated"] == "2026-05-20" assert record["downloads"] == 5000000 assert record["overall_status"] == "pending" print(" [OK] ensure_download_record + get_download_record") # Test ensure_download_record updates existing registry.ensure_download_record( package_name="com.test.dl", country_code="US", app_name="Test DL App Updated", last_updated="2026-05-25", downloads=6000000, ) record = registry.get_download_record("com.test.dl", "US") assert record["app_name"] == "Test DL App Updated" assert record["last_updated"] == "2026-05-25" assert record["downloads"] == 6000000 print(" [OK] ensure_download_record update") # Test update_source_result registry.update_source_result("com.test.dl", "US", "us_play", "success", download_date="2026-05-21 10:00:00") registry.update_source_result("com.test.dl", "US", "us_aurora", "not_attempted") record = registry.get_download_record("com.test.dl", "US") assert record["us_play_status"] == "success" assert record["us_play_date"] == "2026-05-21 10:00:00" assert record["us_aurora_status"] == "not_attempted" assert record["overall_status"] == "success" print(" [OK] update_source_result (success)") # Test update_source_result (failure) registry.update_source_result("com.test.dl", "US", "worker_play", "failed", error="network timeout") record = registry.get_download_record("com.test.dl", "US") assert record["worker_play_status"] == "failed" assert record["worker_play_error"] == "network timeout" assert record["overall_status"] == "success" # still overall success b/c us_play succeeded print(" [OK] update_source_result (failure, but overall still success)") # Test all_failed registry.update_source_result("com.test.dl", "US", "us_play", "failed", error="region restricted") record = registry.get_download_record("com.test.dl", "US") assert record["us_play_status"] == "failed" assert record["overall_status"] == "all_failed" assert "region restricted" in (record["overall_error"] or "") print(" [OK] overall_status = all_failed") # Test record_download_from_result (MinIO result) result = { "status": "ok", "download_date": "2026-05-21 11:00:00", "version_name": "2.0", "files": [{"filename": "base.apk", "size": 5000000}], "source_details": { "us_play": {"status": "success", "error": None, "date": "2026-05-21 11:00:00"}, "us_aurora": {"status": "not_attempted", "error": None, "date": None}, "worker_play": {"status": "not_attempted", "error": None, "date": None}, }, } registry.record_download_from_result("com.test.dl", "US", result) record = registry.get_download_record("com.test.dl", "US") assert record["us_play_status"] == "success" assert record["overall_status"] == "success" assert record["apk_version_name"] == "2.0" assert record["local_apk_file_count"] == 1 print(" [OK] record_download_from_result") # Test record_download_from_result (failed) result_failed = { "status": "failed", "source_details": { "us_play": {"status": "failed", "error": "region restricted", "date": None}, "us_aurora": {"status": "failed", "error": "app not found", "date": None}, }, } registry.record_download_from_result("com.test.dl", "US", result_failed) record = registry.get_download_record("com.test.dl", "US") assert record["us_play_status"] == "failed" assert record["us_aurora_status"] == "failed" assert record["overall_status"] == "all_failed" print(" [OK] record_download_from_result (failed)") # Test list_download_records all_records = registry.list_download_records() assert len(all_records) >= 1 us_records = registry.list_download_records(country_code="US") assert len(us_records) == 1 failed_records = registry.list_download_records(overall_status="all_failed") assert len(failed_records) == 1 print(f" [OK] list_download_records: total={len(all_records)}, US={len(us_records)}, failed={len(failed_records)}") # Test get_stale_records # register an APK older than last_updated to trigger stale registry.mark_available( "com.test.dl", download_date="2026-05-01", local_dir="/tmp/app_dl", version_name="1.0", source="test", ) stale_records = registry.get_stale_records() assert any(r["package_name"] == "com.test.dl" for r in stale_records), \ f"Expected com.test.dl to be stale, got: {[r['package_name'] for r in stale_records]}" print(f" [OK] get_stale_records: {len(stale_records)} stale packages") # Test cleanup_stale_local # This removes the apk_registry entry (simulating deletion on disk) # Since /tmp/app_dl doesn't exist, it should return True cleaned = registry.cleanup_stale_local("com.test.dl") print(f" [OK] cleanup_stale_local: {cleaned}") # Cleanup download record test data with registry._connect() as conn: conn.execute("DELETE FROM app_download_record WHERE package_name = ?", ("com.test.dl",)) print(" [OK] download record cleanup") # Cleanup registry.mark_unavailable("com.test.app1") registry.clear_pending_download("com.test.app1") registry.clear_pending_download("com.test.need_apk") registry.mark_unavailable("com.test.need_apk") registry.mark_unavailable("com.test.dl") assert registry.get("com.test.app1") is None print("\nAll tests passed!") print(f"\nDB path: {registry.db_path}") print(f"Storage dir: {registry.storage_dir}") print(f"SMB dir: {registry.smb_dir}") print(f"Packages: {registry.list_all_packages()}") return 0 if __name__ == "__main__": raise SystemExit(main())