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

1068 lines
43 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
"""
本地 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())