2284 lines
97 KiB
Python
Executable File
2284 lines
97 KiB
Python
Executable File
import asyncio
|
|
import aiohttp
|
|
import os
|
|
import re
|
|
import time
|
|
import shutil
|
|
import urllib.parse
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
|
|
from config import config_manager, Settings
|
|
from core.log_manager import get_writer, LogModule
|
|
from core.driver_base import _extra_api_delay
|
|
from core.driver_service import build_upstream_download_headers, get_account_driver, resolve_download
|
|
from core.strm_security import (
|
|
build_strm_play_path,
|
|
build_strm_v2_play_path,
|
|
decode_strm_file_key,
|
|
sign_strm_path,
|
|
)
|
|
from database.db import db
|
|
|
|
|
|
DEFAULT_METADATA_EXTENSIONS = "srt;ass;ssa;sub;nfo;jpg;jpeg;png;webp"
|
|
DEFAULT_MEDIA_EXTENSIONS = "mp4;mkv;avi;mov;wmv;flv;ts;m2ts;mpg;mpeg;webm;m4v;iso;rmvb;mp3;flac;aac;wav;m4a"
|
|
|
|
# Linux ext4 等:单路径分量名上限一般为 255 字节(UTF-8)
|
|
_STRM_MAX_COMPONENT_BYTES = 255
|
|
STRM_LINK_FORMATS = {"v1", "v2"}
|
|
|
|
|
|
@dataclass
|
|
class StrmSyncTask:
|
|
id: int
|
|
name: str
|
|
account_id: int
|
|
parent_id: str
|
|
path: str
|
|
recursive: bool
|
|
scan_interval: int
|
|
scan_mode: str
|
|
concurrency: int
|
|
extensions: str
|
|
exclude_dir_keywords: str
|
|
exclude_file_keywords: str
|
|
sync_metadata: bool
|
|
status: str
|
|
api_interval: int = 200
|
|
file_count: int = 0
|
|
last_created_count: int = 0
|
|
last_updated_count: int = 0
|
|
last_deleted_count: int = 0
|
|
last_duration_ms: int = 0
|
|
last_scan: Optional[datetime] = None
|
|
last_scan_status: Optional[str] = None
|
|
error_message: Optional[str] = None
|
|
next_run_time: Optional[datetime] = None
|
|
paused_reason: Optional[str] = None # None / 'user' / 'account_disabled' / 'auth_failure'
|
|
time_window_enabled: bool = False
|
|
time_start: str = "00:00"
|
|
time_end: str = "00:00"
|
|
branch_check_enabled: bool = False
|
|
scanned_dirs: int = 0
|
|
scanned_files: int = 0
|
|
started_at: Optional[datetime] = None
|
|
|
|
|
|
class StrmSyncManager:
|
|
_FIXED_WRITE_CONCURRENCY = 3
|
|
|
|
def __init__(self):
|
|
self._tasks: Dict[int, StrmSyncTask] = {}
|
|
self._running_tasks: Set[int] = set()
|
|
self._queued_tasks: Set[int] = set()
|
|
self._manual_triggered_tasks: Set[int] = set()
|
|
self._pending_run_modes: Dict[int, str] = {}
|
|
self._running_account_ids: Set[int] = set()
|
|
self._task_concurrency_limit = 3
|
|
self._scheduler_task: Optional[asyncio.Task] = None
|
|
self._running_task_futures: Dict[int, asyncio.Task] = {}
|
|
try:
|
|
self._logger = get_writer(LogModule.SYSTEM)
|
|
except RuntimeError:
|
|
self._logger = None
|
|
self._is_running = False
|
|
self._strm_root = Path("strm")
|
|
self._startup_ready_at: float = 0.0
|
|
self._startup_delay_seconds: int = 100
|
|
self._defer_to_cache_retention_seconds: int = 15
|
|
|
|
async def _get_runtime_settings(self) -> Tuple[int, int, int]:
|
|
scan_interval = await config_manager.get_async("strm_default_scan_interval")
|
|
task_concurrency = await config_manager.get_async("strm_task_concurrency")
|
|
if task_concurrency is None:
|
|
task_concurrency = 3
|
|
await config_manager.set_async("strm_task_concurrency", int(task_concurrency))
|
|
return int(scan_interval or 60), self._FIXED_WRITE_CONCURRENCY, max(1, min(int(task_concurrency), 10))
|
|
|
|
async def initialize(self):
|
|
if not self._logger:
|
|
self._logger = get_writer(LogModule.SYSTEM)
|
|
scan_interval, concurrency, task_concurrency_limit = await self._get_runtime_settings()
|
|
self._task_concurrency_limit = task_concurrency_limit
|
|
tasks = await db.get_strm_sync_tasks()
|
|
previous_running_tasks = set(self._running_tasks)
|
|
previous_queued_tasks = set(self._queued_tasks)
|
|
previous_manual_triggered_tasks = set(self._manual_triggered_tasks)
|
|
previous_pending_run_modes = dict(self._pending_run_modes)
|
|
self._tasks = {}
|
|
for row in tasks:
|
|
last_scan = None
|
|
if row.get("last_scan"):
|
|
try:
|
|
last_scan = datetime.fromisoformat(str(row["last_scan"]).replace("Z", "+00:00"))
|
|
except Exception:
|
|
last_scan = None
|
|
|
|
task = StrmSyncTask(
|
|
id=int(row["id"]),
|
|
name=str(row["name"]),
|
|
account_id=int(row["account_id"]),
|
|
parent_id=str(row["parent_id"]),
|
|
path=str(row["path"]),
|
|
recursive=True,
|
|
scan_interval=scan_interval,
|
|
scan_mode=str(row.get("scan_mode") or "incremental_missing"),
|
|
concurrency=concurrency,
|
|
extensions=str(row.get("extensions") or ""),
|
|
exclude_dir_keywords=str(row.get("exclude_dir_keywords") or ""),
|
|
exclude_file_keywords=str(row.get("exclude_file_keywords") or ""),
|
|
sync_metadata=bool(row.get("sync_metadata") or False),
|
|
api_interval=int(row.get("api_interval") or Settings.STRM_DEFAULT_API_INTERVAL),
|
|
status=str(row.get("status") or "running"),
|
|
file_count=int(row.get("file_count") or 0),
|
|
last_created_count=int(row.get("last_created_count") or 0),
|
|
last_updated_count=int(row.get("last_updated_count") or 0),
|
|
last_deleted_count=int(row.get("last_deleted_count") or 0),
|
|
last_duration_ms=int(row.get("last_duration_ms") or 0),
|
|
last_scan=last_scan,
|
|
last_scan_status=row.get("last_scan_status"),
|
|
error_message=row.get("error_message"),
|
|
paused_reason=row.get("paused_reason"),
|
|
time_window_enabled=bool(row.get("time_window_enabled") or False),
|
|
time_start=str(row.get("time_start") or "00:00"),
|
|
time_end=str(row.get("time_end") or "00:00"),
|
|
branch_check_enabled=bool(row.get("branch_check_enabled") or False),
|
|
)
|
|
|
|
if task.status == "running" and task.last_scan:
|
|
task.next_run_time = task.last_scan + timedelta(minutes=scan_interval)
|
|
elif task.status == "running":
|
|
task.next_run_time = datetime.now()
|
|
else:
|
|
task.next_run_time = None
|
|
|
|
self._tasks[task.id] = task
|
|
|
|
self._running_tasks = {task_id for task_id in previous_running_tasks if task_id in self._tasks}
|
|
self._queued_tasks = {
|
|
task_id for task_id in previous_queued_tasks
|
|
if task_id in self._tasks and task_id not in self._running_tasks
|
|
}
|
|
self._manual_triggered_tasks = {
|
|
task_id for task_id in previous_manual_triggered_tasks
|
|
if task_id in self._tasks and task_id not in self._running_tasks
|
|
}
|
|
self._pending_run_modes = {
|
|
task_id: mode
|
|
for task_id, mode in previous_pending_run_modes.items()
|
|
if task_id in self._tasks and task_id not in self._running_tasks
|
|
}
|
|
self._running_account_ids = {
|
|
self._tasks[task_id].account_id
|
|
for task_id in self._running_tasks
|
|
if task_id in self._tasks
|
|
}
|
|
|
|
self._logger.debug(f"🎞️ STRM同步管理器初始化完成,加载了 {len(self._tasks)} 个任务")
|
|
|
|
@property
|
|
def startup_remaining(self) -> int:
|
|
return max(0, int(self._startup_ready_at - time.time()))
|
|
|
|
async def start(self):
|
|
if self._is_running:
|
|
return
|
|
self._is_running = True
|
|
self._startup_ready_at = time.time() + self._startup_delay_seconds + self._defer_to_cache_retention_seconds
|
|
self._logger.info(f"STRM 调度器将在 {self._startup_delay_seconds + self._defer_to_cache_retention_seconds}s 后开始执行任务(含 {self._defer_to_cache_retention_seconds}s 缓存保持优先延迟)")
|
|
self._scheduler_task = asyncio.create_task(self._scheduler_loop())
|
|
|
|
async def stop(self):
|
|
self._is_running = False
|
|
if self._scheduler_task and not self._scheduler_task.done():
|
|
self._scheduler_task.cancel()
|
|
try:
|
|
await self._scheduler_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
for future in list(self._running_task_futures.values()):
|
|
if not future.done():
|
|
future.cancel()
|
|
if self._running_task_futures:
|
|
await asyncio.gather(*self._running_task_futures.values(), return_exceptions=True)
|
|
self._running_task_futures.clear()
|
|
|
|
def _sanitize_task_folder(self, name: str) -> str:
|
|
safe = self._sanitize_path_component(name, kind="task")
|
|
safe = re.sub(r"[\\\\/:*?\"<>|]+", "_", safe).strip()
|
|
return safe or "task"
|
|
|
|
def _contains_embedded_null(self, value: str) -> bool:
|
|
return "\x00" in str(value or "")
|
|
|
|
def _is_embedded_null_error(self, err: BaseException) -> bool:
|
|
return "null" in str(err).lower()
|
|
|
|
def _sanitize_path_component(
|
|
self,
|
|
value: str,
|
|
*,
|
|
kind: str,
|
|
file_id: str = "",
|
|
parent_path: str = "",
|
|
) -> str:
|
|
raw = str(value or "")
|
|
if not self._contains_embedded_null(raw):
|
|
return raw
|
|
cleaned = raw.replace("\x00", "").strip()
|
|
if self._logger:
|
|
self._logger.warning(
|
|
"STRM路径含隐藏空字符已自动清理: kind=%s file_id=%s parent=%s raw=%r cleaned=%r null_count=%d",
|
|
kind,
|
|
file_id or "-",
|
|
parent_path or "-",
|
|
raw,
|
|
cleaned,
|
|
raw.count("\x00"),
|
|
)
|
|
if cleaned:
|
|
return cleaned
|
|
return {"file": "metadata", "dir": "folder", "folder": "folder", "task": "task"}.get(kind, "item")
|
|
|
|
def _log_strm_path_error(
|
|
self,
|
|
err: BaseException,
|
|
*,
|
|
action: str,
|
|
file_id: str = "",
|
|
file_name: str = "",
|
|
relative_dir: Any = "",
|
|
remote_path: str = "",
|
|
local_path: Any = "",
|
|
) -> None:
|
|
if not self._logger:
|
|
return
|
|
level = "error" if self._is_embedded_null_error(err) else "warning"
|
|
message = (
|
|
f"STRM路径错误({action}): {err} | "
|
|
f"file_id={file_id or '-'} file_name={file_name!r} "
|
|
f"relative_dir={relative_dir or '-'} remote_path={remote_path or '-'} "
|
|
f"local_path={local_path or '-'}"
|
|
)
|
|
getattr(self._logger, level)(message)
|
|
|
|
def _get_task_root(self, task_name: str) -> Path:
|
|
return self._strm_root / self._sanitize_task_folder(task_name)
|
|
|
|
def _strm_path_has_oversized_component(self, path: Path) -> bool:
|
|
"""任一路径段长度超过系统单分量上限则无法创建文件(跳过 STRM,不截断)。"""
|
|
for part in path.parts:
|
|
if part in ("/", "\\", ""):
|
|
continue
|
|
try:
|
|
if len(os.fsencode(part)) > _STRM_MAX_COMPONENT_BYTES:
|
|
return True
|
|
except Exception:
|
|
return True
|
|
return False
|
|
|
|
def _parse_extensions(self, text: str) -> Set[str]:
|
|
default_exts = {
|
|
"mp4", "mkv", "avi", "mov", "wmv", "flv", "ts", "m2ts",
|
|
"mpg", "mpeg", "webm", "m4v", "iso", "rmvb",
|
|
"mp3", "flac", "aac", "wav", "m4a",
|
|
}
|
|
raw = (text or "").strip()
|
|
if not raw:
|
|
return default_exts
|
|
parts = [p.strip().lower().lstrip(".") for p in raw.split(";") if p.strip()]
|
|
return set(parts) or default_exts
|
|
|
|
def _parse_optional_extensions(self, text: str) -> Set[str]:
|
|
raw = str(text or "").strip()
|
|
if not raw:
|
|
return set()
|
|
raw = raw.replace(",", ";")
|
|
return {p.strip().lower().lstrip(".") for p in raw.split(";") if p.strip()}
|
|
|
|
async def _get_bool_setting(self, key: str, default: bool) -> bool:
|
|
value = await config_manager.get_async(key)
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, (int, float)):
|
|
return bool(value)
|
|
text = str(value).strip().lower()
|
|
if text in ("0", "false", "no", "off", ""):
|
|
return False
|
|
if text in ("1", "true", "yes", "on"):
|
|
return True
|
|
return default
|
|
|
|
async def _get_int_setting(self, key: str, default: int, min_value: int, max_value: int) -> int:
|
|
value = await config_manager.get_async(key)
|
|
try:
|
|
number = int(value)
|
|
except Exception:
|
|
number = default
|
|
return max(min_value, min(number, max_value))
|
|
|
|
async def _get_str_setting(self, key: str, default: str) -> str:
|
|
value = await config_manager.get_async(key)
|
|
if value is None:
|
|
return default
|
|
return str(value or "")
|
|
|
|
def _parse_keyword_rules(self, text: str) -> List[str]:
|
|
return [item.strip().lower() for item in str(text or "").split(";") if item.strip()]
|
|
|
|
def _matches_keyword_rules(self, text: str, rules: List[str]) -> bool:
|
|
candidate = str(text or "").lower()
|
|
return any(rule in candidate for rule in rules)
|
|
|
|
async def _get_strm_base_url(self) -> str:
|
|
value = await config_manager.get_async("strm_base_url")
|
|
configured = str(value or "").strip().rstrip("/")
|
|
if configured:
|
|
return configured
|
|
return f"http://127.0.0.1:{int(Settings.WEB_PORT)}"
|
|
|
|
async def _get_strm_link_format(self) -> str:
|
|
value = str(await config_manager.get_async("strm_link_format") or "v1").strip().lower()
|
|
return value if value in STRM_LINK_FORMATS else "v1"
|
|
|
|
async def _get_strm_token(self) -> str:
|
|
value = await config_manager.get_async("strm_token")
|
|
token = str(value or "").strip()
|
|
if token:
|
|
return token
|
|
import secrets
|
|
token = secrets.token_urlsafe(32)
|
|
await config_manager.set_async("strm_token", token)
|
|
return token
|
|
|
|
async def _scheduler_loop(self):
|
|
while self._is_running:
|
|
remaining = self.startup_remaining
|
|
if remaining > 0:
|
|
await asyncio.sleep(1)
|
|
continue
|
|
|
|
_, _, task_concurrency_limit = await self._get_runtime_settings()
|
|
self._task_concurrency_limit = task_concurrency_limit
|
|
now = datetime.now()
|
|
runnable: List[Tuple[int, datetime]] = []
|
|
for task_id, task in self._tasks.items():
|
|
manual_triggered = task_id in self._manual_triggered_tasks
|
|
if task.status != "running" and not manual_triggered:
|
|
self._queued_tasks.discard(task_id)
|
|
continue
|
|
if task_id in self._running_tasks:
|
|
continue
|
|
if not manual_triggered and not self._is_in_time_window(task):
|
|
continue
|
|
if task.next_run_time and task.next_run_time <= now:
|
|
runnable.append((task_id, task.next_run_time))
|
|
|
|
runnable.sort(key=lambda x: x[1])
|
|
for task_id, _ in runnable:
|
|
self._start_task_if_possible(task_id)
|
|
|
|
await asyncio.sleep(2)
|
|
|
|
@staticmethod
|
|
def _is_in_time_window(task: StrmSyncTask) -> bool:
|
|
if not task.time_window_enabled:
|
|
return True
|
|
try:
|
|
now = datetime.now().time()
|
|
start_h, start_m = map(int, task.time_start.split(":"))
|
|
end_h, end_m = map(int, task.time_end.split(":"))
|
|
start_t = datetime.now().replace(hour=start_h, minute=start_m, second=0, microsecond=0).time()
|
|
end_t = datetime.now().replace(hour=end_h, minute=end_m, second=0, microsecond=0).time()
|
|
if start_t <= end_t:
|
|
return start_t <= now <= end_t
|
|
else:
|
|
return now >= start_t or now <= end_t
|
|
except (ValueError, AttributeError):
|
|
return True
|
|
|
|
def _can_start_task(self, task: Optional[StrmSyncTask]) -> bool:
|
|
if not task:
|
|
return False
|
|
if len(self._running_tasks) >= self._task_concurrency_limit:
|
|
return False
|
|
if task.account_id in self._running_account_ids:
|
|
return False
|
|
try:
|
|
from core.cache_retention_manager import cache_retention_manager
|
|
if task.account_id in cache_retention_manager.get_running_account_ids():
|
|
return False
|
|
except Exception:
|
|
pass
|
|
return True
|
|
|
|
def _start_task_if_possible(self, task_id: int) -> bool:
|
|
task = self._tasks.get(task_id)
|
|
allow_manual = task_id in self._manual_triggered_tasks
|
|
if not task or (task.status != "running" and not allow_manual):
|
|
self._queued_tasks.discard(task_id)
|
|
self._manual_triggered_tasks.discard(task_id)
|
|
return False
|
|
if task_id in self._running_tasks:
|
|
self._queued_tasks.discard(task_id)
|
|
return True
|
|
if not self._can_start_task(task):
|
|
self._queued_tasks.add(task_id)
|
|
return False
|
|
self._queued_tasks.discard(task_id)
|
|
self._manual_triggered_tasks.discard(task_id)
|
|
run_mode = self._pending_run_modes.pop(task_id, "auto")
|
|
self._running_tasks.add(task_id)
|
|
self._running_account_ids.add(task.account_id)
|
|
future = asyncio.create_task(self._run_task(task_id, run_mode=run_mode))
|
|
self._running_task_futures[task_id] = future
|
|
return True
|
|
|
|
async def run_task_now(self, task_id: int, run_mode: str = "auto") -> str:
|
|
task = self._tasks.get(task_id)
|
|
if not task:
|
|
return "missing"
|
|
if run_mode not in {"auto", "full", "branch"}:
|
|
run_mode = "auto"
|
|
task.next_run_time = datetime.now()
|
|
if task_id in self._running_tasks:
|
|
self._queued_tasks.discard(task_id)
|
|
return "already_running"
|
|
self._pending_run_modes[task_id] = run_mode
|
|
if task_id in self._queued_tasks:
|
|
return "already_queued"
|
|
self._manual_triggered_tasks.add(task_id)
|
|
if self._start_task_if_possible(task_id):
|
|
return "running"
|
|
self._queued_tasks.add(task_id)
|
|
return "queued"
|
|
|
|
async def force_stop_task(self, task_id: int) -> bool:
|
|
"""强制停止正在执行的 STRM 任务,不改变状态,不影响下次调度。"""
|
|
task = self._tasks.get(task_id)
|
|
if not task:
|
|
return False
|
|
|
|
future = self._running_task_futures.get(task_id)
|
|
if future and not future.done():
|
|
future.cancel()
|
|
|
|
self._running_tasks.discard(task_id)
|
|
self._running_task_futures.pop(task_id, None)
|
|
self._pending_run_modes.pop(task_id, None)
|
|
self._running_account_ids.discard(task.account_id)
|
|
|
|
if task.next_run_time is None:
|
|
task.next_run_time = datetime.now()
|
|
|
|
self._logger.info(f"强制停止 STRM 任务 {task_id}(下次调度不受影响)")
|
|
return True
|
|
|
|
def get_running_task_ids(self) -> Set[int]:
|
|
return set(self._running_tasks)
|
|
|
|
def get_queued_task_ids(self) -> Set[int]:
|
|
return set(self._queued_tasks)
|
|
|
|
def get_running_account_ids(self) -> Set[int]:
|
|
"""返回当前正在执行 STRM 任务的账号 ID 集合,供缓存保持管理器跨管理器互斥。"""
|
|
return set(self._running_account_ids)
|
|
|
|
async def refresh_tasks_from_db(self):
|
|
await self.initialize()
|
|
|
|
async def _collect_files(
|
|
self,
|
|
driver,
|
|
parent_id: str,
|
|
base_posix_path: str,
|
|
recursive: bool,
|
|
extensions: Set[str],
|
|
metadata_extensions: Set[str],
|
|
metadata_max_size_bytes: int,
|
|
min_media_size_bytes: int,
|
|
metadata_parent_enabled: bool,
|
|
exclude_dir_rules: List[str],
|
|
exclude_file_rules: List[str],
|
|
relative_parts: PurePosixPath,
|
|
result: List[Tuple[str, str, str, int, Optional[datetime], PurePosixPath]],
|
|
metadata_result: Optional[List[Tuple[str, str, int, Optional[datetime], PurePosixPath]]] = None,
|
|
task: Optional[StrmSyncTask] = None,
|
|
) -> bool:
|
|
files = await driver.list_files(parent_id)
|
|
return await self._collect_files_from_items(
|
|
driver,
|
|
files,
|
|
base_posix_path,
|
|
recursive,
|
|
extensions,
|
|
metadata_extensions,
|
|
metadata_max_size_bytes,
|
|
min_media_size_bytes,
|
|
metadata_parent_enabled,
|
|
exclude_dir_rules,
|
|
exclude_file_rules,
|
|
relative_parts,
|
|
result,
|
|
metadata_result,
|
|
task,
|
|
)
|
|
|
|
async def _collect_files_from_items(
|
|
self,
|
|
driver,
|
|
files: List[Any],
|
|
base_posix_path: str,
|
|
recursive: bool,
|
|
extensions: Set[str],
|
|
metadata_extensions: Set[str],
|
|
metadata_max_size_bytes: int,
|
|
min_media_size_bytes: int,
|
|
metadata_parent_enabled: bool,
|
|
exclude_dir_rules: List[str],
|
|
exclude_file_rules: List[str],
|
|
relative_parts: PurePosixPath,
|
|
result: List[Tuple[str, str, str, int, Optional[datetime], PurePosixPath]],
|
|
metadata_result: Optional[List[Tuple[str, str, int, Optional[datetime], PurePosixPath]]] = None,
|
|
task: Optional[StrmSyncTask] = None,
|
|
) -> bool:
|
|
directory_metadata: List[Tuple[str, str, int, Optional[datetime], PurePosixPath]] = []
|
|
directory_has_media = False
|
|
subtree_has_media = False
|
|
for item in files:
|
|
item_id = str(item.id)
|
|
name = self._sanitize_path_component(
|
|
str(item.name),
|
|
kind="dir" if item.is_dir else "file",
|
|
file_id=item_id,
|
|
parent_path=str(relative_parts),
|
|
)
|
|
if item.is_dir:
|
|
if self._matches_keyword_rules(name, exclude_dir_rules):
|
|
continue
|
|
if recursive:
|
|
child_has_media = await self._collect_files(
|
|
driver,
|
|
item_id,
|
|
base_posix_path,
|
|
recursive,
|
|
extensions,
|
|
metadata_extensions,
|
|
metadata_max_size_bytes,
|
|
min_media_size_bytes,
|
|
metadata_parent_enabled,
|
|
exclude_dir_rules,
|
|
exclude_file_rules,
|
|
relative_parts / name,
|
|
result,
|
|
metadata_result,
|
|
task=task,
|
|
)
|
|
subtree_has_media = subtree_has_media or child_has_media
|
|
continue
|
|
|
|
if self._matches_keyword_rules(name, exclude_file_rules):
|
|
continue
|
|
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
|
size = int(getattr(item, "size", 0) or 0)
|
|
if metadata_result is not None and ext in metadata_extensions:
|
|
if metadata_max_size_bytes <= 0 or size <= metadata_max_size_bytes:
|
|
directory_metadata.append(
|
|
(item_id, name, size, getattr(item, "modified", None), relative_parts)
|
|
)
|
|
continue
|
|
if ext not in extensions:
|
|
continue
|
|
if min_media_size_bytes > 0 and size < min_media_size_bytes:
|
|
continue
|
|
|
|
remote_rel = relative_parts / name
|
|
remote_path = str(PurePosixPath(base_posix_path) / remote_rel)
|
|
directory_has_media = True
|
|
if task:
|
|
task.scanned_files += 1
|
|
result.append(
|
|
(
|
|
item_id,
|
|
name,
|
|
remote_path,
|
|
size,
|
|
getattr(item, "modified", None),
|
|
relative_parts,
|
|
)
|
|
)
|
|
if task:
|
|
task.scanned_dirs += 1
|
|
subtree_has_media = subtree_has_media or directory_has_media
|
|
if metadata_result is not None and (directory_has_media or (metadata_parent_enabled and subtree_has_media)):
|
|
metadata_result.extend(directory_metadata)
|
|
return subtree_has_media
|
|
|
|
def _media_stem(self, file_name: str) -> str:
|
|
name = str(file_name or "")
|
|
return name.rsplit(".", 1)[0] if "." in name else name
|
|
|
|
def _build_local_rel_path(self, task_folder: str, relative_dir: PurePosixPath, file_name: str) -> str:
|
|
safe_name = self._safe_child_name(self._media_stem(file_name))
|
|
local_rel = PurePosixPath(task_folder) / relative_dir / f"{safe_name}.strm"
|
|
return str(local_rel)
|
|
|
|
def _build_legacy_local_rel_path(self, task_folder: str, relative_dir: PurePosixPath, file_name: str) -> Optional[str]:
|
|
safe_name = self._safe_child_name(file_name)
|
|
local_rel = PurePosixPath(task_folder) / relative_dir / f"{safe_name}.strm"
|
|
return str(local_rel)
|
|
|
|
def _build_play_url(
|
|
self,
|
|
base_url: str,
|
|
account_id: int,
|
|
file_id: str,
|
|
token: str,
|
|
signature_enabled: bool,
|
|
link_format: str = "v1",
|
|
) -> str:
|
|
if link_format == "v2":
|
|
return f"{base_url}{build_strm_v2_play_path(account_id, file_id, token, signature_enabled)}"
|
|
|
|
play_path = build_strm_play_path(account_id, file_id)
|
|
params = [("token", token)]
|
|
if signature_enabled:
|
|
params.append(("sign", sign_strm_path(play_path)))
|
|
return f"{base_url}{play_path}?{urllib.parse.urlencode(params)}"
|
|
|
|
def _normalize_display_path(self, path: str) -> str:
|
|
value = "/" + str(path or "").strip("/")
|
|
return "/" if value == "/" else value.rstrip("/")
|
|
|
|
def _relative_display_path(self, task_path: str, current_path: str) -> Optional[PurePosixPath]:
|
|
task_norm = self._normalize_display_path(task_path)
|
|
current_norm = self._normalize_display_path(current_path)
|
|
if current_norm == task_norm:
|
|
return PurePosixPath()
|
|
prefix = task_norm.rstrip("/") + "/"
|
|
if current_norm.startswith(prefix):
|
|
return PurePosixPath(current_norm[len(prefix):].strip("/"))
|
|
return None
|
|
|
|
def _parse_item_modified(self, value: object) -> Optional[datetime]:
|
|
if not value:
|
|
return None
|
|
if isinstance(value, datetime):
|
|
return value
|
|
try:
|
|
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
except Exception:
|
|
return None
|
|
|
|
async def generate_current_directory_strm(
|
|
self,
|
|
account_id: int,
|
|
current_path: str,
|
|
items: List[Dict[str, object]],
|
|
) -> Dict[str, int]:
|
|
"""按需同步当前目录的浅层 STRM,不递归、不同步元数据。"""
|
|
tasks = await db.get_strm_sync_tasks()
|
|
candidates: List[Tuple[int, Dict[str, object], PurePosixPath]] = []
|
|
current_norm = self._normalize_display_path(current_path)
|
|
for task in tasks:
|
|
if int(task.get("account_id") or 0) != int(account_id):
|
|
continue
|
|
relative_dir = self._relative_display_path(str(task.get("path") or ""), current_norm)
|
|
if relative_dir is None:
|
|
continue
|
|
task_path_len = len(self._normalize_display_path(str(task.get("path") or "")))
|
|
candidates.append((task_path_len, task, relative_dir))
|
|
|
|
if not candidates:
|
|
return {
|
|
"matched_task_id": 0,
|
|
"created": 0,
|
|
"updated": 0,
|
|
"skipped_existing": 0,
|
|
"skipped_conflict": 0,
|
|
"skipped_path_too_long": 0,
|
|
"deleted": 0,
|
|
"media_count": 0,
|
|
}
|
|
|
|
_task_path_len, task, relative_dir = max(candidates, key=lambda item: item[0])
|
|
task_id = int(task.get("id") or 0)
|
|
task_name = str(task.get("name") or "")
|
|
task_folder = self._sanitize_task_folder(task_name)
|
|
task_root = self._get_task_root(task_name)
|
|
task_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
base_url = await self._get_strm_base_url()
|
|
token = await self._get_strm_token()
|
|
link_format = await self._get_strm_link_format()
|
|
signature_enabled = await self._get_bool_setting("strm_signature_enabled", False)
|
|
default_extensions = await self._get_str_setting("strm_default_extensions", DEFAULT_MEDIA_EXTENSIONS)
|
|
extensions = self._parse_extensions(str(task.get("extensions") or "") or default_extensions)
|
|
min_media_size_mb = await self._get_int_setting("strm_min_file_size_mb", 0, 0, 10240)
|
|
min_media_size_bytes = min_media_size_mb * 1024 * 1024
|
|
conflict_policy = self._normalize_conflict_policy(
|
|
await self._get_str_setting("strm_conflict_policy", "size_desc")
|
|
)
|
|
scan_mode = str(task.get("scan_mode") or "incremental_update")
|
|
|
|
collected: List[Tuple[str, str, str, int, Optional[datetime], PurePosixPath]] = []
|
|
current_base = self._normalize_display_path(current_norm)
|
|
remote_dir_names: Set[str] = set()
|
|
for item in items or []:
|
|
if bool(item.get("is_dir")):
|
|
name = str(item.get("name") or "").strip()
|
|
if name:
|
|
remote_dir_names.add(name)
|
|
continue
|
|
file_name = str(item.get("name") or "")
|
|
ext = file_name.rsplit(".", 1)[-1].lower() if "." in file_name else ""
|
|
if ext not in extensions:
|
|
continue
|
|
size = int(item.get("size") or 0)
|
|
if min_media_size_bytes > 0 and size < min_media_size_bytes:
|
|
continue
|
|
file_id = str(item.get("id") or "")
|
|
if not file_id:
|
|
continue
|
|
remote_path = str(PurePosixPath(current_base) / file_name)
|
|
collected.append(
|
|
(
|
|
file_id,
|
|
file_name,
|
|
remote_path,
|
|
size,
|
|
self._parse_item_modified(item.get("modified")),
|
|
relative_dir,
|
|
)
|
|
)
|
|
|
|
selected, skipped_conflict = self._select_conflict_winners(collected, conflict_policy)
|
|
created = 0
|
|
updated = 0
|
|
skipped_existing = 0
|
|
deleted = 0
|
|
desired_abs_paths: Set[Path] = set()
|
|
|
|
skipped_path_too_long = 0
|
|
for file_id, file_name, _remote_path, _size, _modified, item_relative_dir in selected:
|
|
local_rel_path = self._build_local_rel_path(task_folder, item_relative_dir, file_name)
|
|
local_abs_path = task_root / PurePosixPath(local_rel_path).relative_to(task_folder)
|
|
if self._strm_path_has_oversized_component(local_abs_path):
|
|
skipped_path_too_long += 1
|
|
if self._logger:
|
|
self._logger.warning(
|
|
f"STRM跳过(路径分量超过{_STRM_MAX_COMPONENT_BYTES}字节): {file_name}"
|
|
)
|
|
continue
|
|
desired_abs_paths.add(local_abs_path)
|
|
try:
|
|
local_abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
except (OSError, ValueError) as err:
|
|
self._log_strm_path_error(
|
|
err,
|
|
action="mkdir",
|
|
file_id=file_id,
|
|
file_name=file_name,
|
|
relative_dir=item_relative_dir,
|
|
local_path=local_abs_path,
|
|
)
|
|
continue
|
|
local_exists = local_abs_path.exists()
|
|
|
|
if not local_exists:
|
|
legacy_rel_path = self._build_legacy_local_rel_path(task_folder, item_relative_dir, file_name)
|
|
if legacy_rel_path and legacy_rel_path != local_rel_path:
|
|
legacy_abs_path = task_root / PurePosixPath(legacy_rel_path).relative_to(task_folder)
|
|
if legacy_abs_path.exists():
|
|
try:
|
|
legacy_abs_path.replace(local_abs_path)
|
|
local_exists = True
|
|
except OSError as err:
|
|
if self._logger:
|
|
self._logger.warning(f"STRM旧格式迁移失败: {legacy_abs_path.name} - {err}")
|
|
|
|
url = self._build_play_url(base_url, int(account_id), file_id, token, signature_enabled, link_format)
|
|
if local_exists:
|
|
if scan_mode == "incremental_missing":
|
|
skipped_existing += 1
|
|
continue
|
|
try:
|
|
if local_abs_path.read_text(encoding="utf-8").strip() == url:
|
|
skipped_existing += 1
|
|
continue
|
|
except Exception:
|
|
pass
|
|
updated += 1
|
|
else:
|
|
created += 1
|
|
try:
|
|
local_abs_path.write_text(url + "\n", encoding="utf-8")
|
|
except (OSError, ValueError) as err:
|
|
self._log_strm_path_error(
|
|
err,
|
|
action="write",
|
|
file_id=file_id,
|
|
file_name=file_name,
|
|
relative_dir=item_relative_dir,
|
|
local_path=local_abs_path,
|
|
)
|
|
continue
|
|
|
|
if scan_mode in {"incremental_update", "full_sync"}:
|
|
current_local_dir = task_root if str(relative_dir).strip("/") in {"", "."} else task_root / relative_dir
|
|
if current_local_dir.exists() and current_local_dir.is_dir():
|
|
for file_path in current_local_dir.glob("*.strm"):
|
|
if file_path not in desired_abs_paths:
|
|
try:
|
|
file_path.unlink(missing_ok=True)
|
|
deleted += 1
|
|
except OSError as err:
|
|
if self._logger:
|
|
self._logger.warning(f"STRM当前目录清理失败: {file_path.name} - {err}")
|
|
for child in current_local_dir.iterdir():
|
|
if not child.is_dir() or child.name in remote_dir_names:
|
|
continue
|
|
strm_count = sum(1 for _ in child.rglob("*.strm"))
|
|
try:
|
|
shutil.rmtree(child)
|
|
deleted += strm_count
|
|
except OSError as err:
|
|
if self._logger:
|
|
self._logger.warning(f"STRM当前目录子目录清理失败: {child} - {err}")
|
|
|
|
return {
|
|
"matched_task_id": task_id,
|
|
"created": created,
|
|
"updated": updated,
|
|
"skipped_existing": skipped_existing,
|
|
"skipped_conflict": skipped_conflict,
|
|
"skipped_path_too_long": skipped_path_too_long,
|
|
"deleted": deleted,
|
|
"media_count": len(selected),
|
|
}
|
|
|
|
def _safe_child_name(self, name: str) -> str:
|
|
safe = self._sanitize_path_component(name, kind="file")
|
|
safe = safe.replace("/", "_").replace("\\", "_").strip()
|
|
return safe or "metadata"
|
|
|
|
def _normalize_conflict_policy(self, policy: str) -> str:
|
|
normalized = str(policy or "size_desc")
|
|
if normalized == "quality_then_size":
|
|
return "size_desc"
|
|
if normalized not in {"size_desc", "size_asc", "name_asc"}:
|
|
return "size_desc"
|
|
return normalized
|
|
|
|
def _select_conflict_winners(
|
|
self,
|
|
items: List[Tuple[str, str, str, int, Optional[datetime], PurePosixPath]],
|
|
policy: str,
|
|
) -> Tuple[List[Tuple[str, str, str, int, Optional[datetime], PurePosixPath]], int]:
|
|
grouped: Dict[Tuple[str, str], List[Tuple[str, str, str, int, Optional[datetime], PurePosixPath]]] = {}
|
|
for item in items:
|
|
_file_id, file_name, _remote_path, _size, _modified, relative_dir = item
|
|
stem_key = self._safe_child_name(self._media_stem(file_name)).casefold()
|
|
grouped.setdefault((str(relative_dir), stem_key), []).append(item)
|
|
|
|
selected: List[Tuple[str, str, str, int, Optional[datetime], PurePosixPath]] = []
|
|
skipped_count = 0
|
|
normalized_policy = self._normalize_conflict_policy(policy)
|
|
|
|
for group_items in grouped.values():
|
|
if len(group_items) == 1:
|
|
selected.append(group_items[0])
|
|
continue
|
|
|
|
def stable_tail(item):
|
|
file_id, file_name, _remote_path, _size, _modified, _relative_dir = item
|
|
return str(file_name or "").casefold(), str(file_id)
|
|
|
|
if normalized_policy == "size_asc":
|
|
winner = min(group_items, key=lambda item: (int(item[3] or 0), stable_tail(item)))
|
|
elif normalized_policy == "name_asc":
|
|
winner = min(group_items, key=lambda item: stable_tail(item))
|
|
else:
|
|
winner = max(group_items, key=lambda item: (int(item[3] or 0), stable_tail(item)))
|
|
|
|
selected.append(winner)
|
|
skipped_count += len(group_items) - 1
|
|
if self._logger:
|
|
for loser in group_items:
|
|
if loser is winner:
|
|
continue
|
|
self._logger.info(
|
|
f"STRM同名冲突跳过: {loser[1]} 被 {winner[1]} 替代"
|
|
)
|
|
|
|
selected.sort(key=lambda item: (str(item[5]), self._safe_child_name(self._media_stem(item[1])).casefold(), str(item[0])))
|
|
return selected, skipped_count
|
|
|
|
_EPISODE_NAME_PATTERN = re.compile(
|
|
r"(?ix)"
|
|
r"("
|
|
r"s\d{1,2}[\s._-]*e\d{1,3}"
|
|
r"|s\d{1,2}[\s._-]*ep\d{1,3}"
|
|
r"|(?<![a-z0-9])ep?[\s._-]*\d{1,3}(?![a-z0-9])"
|
|
r"|第\s*\d{1,4}\s*[集话話]"
|
|
r")"
|
|
)
|
|
|
|
def _looks_like_episode_file(self, file_name: str, media_extensions: Set[str]) -> bool:
|
|
name = str(file_name or "")
|
|
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
|
if ext not in media_extensions:
|
|
return False
|
|
return bool(self._EPISODE_NAME_PATTERN.search(name))
|
|
|
|
async def _should_auto_add_temporary_branch(
|
|
self,
|
|
driver,
|
|
folder_id: str,
|
|
media_extensions: Set[str],
|
|
) -> bool:
|
|
try:
|
|
items = await driver.list_files(str(folder_id))
|
|
except Exception as err:
|
|
if self._logger:
|
|
self._logger.debug(f"STRM自动分支候选检查失败: folder_id={folder_id} - {err}")
|
|
return False
|
|
|
|
for item in items:
|
|
if getattr(item, "is_dir", False):
|
|
return True
|
|
if self._looks_like_episode_file(str(item.name), media_extensions):
|
|
return True
|
|
return False
|
|
|
|
async def _discover_missing_temporary_branches(
|
|
self,
|
|
driver,
|
|
task: StrmSyncTask,
|
|
task_root: Path,
|
|
base_branch: Dict[str, object],
|
|
existing_parent_ids: Set[str],
|
|
media_extensions: Set[str],
|
|
metadata_extensions: Set[str],
|
|
metadata_max_size_bytes: int,
|
|
min_media_size_bytes: int,
|
|
metadata_parent_enabled: bool,
|
|
exclude_dir_rules: List[str],
|
|
exclude_file_rules: List[str],
|
|
base_posix_path: str,
|
|
result: List[Tuple[str, str, str, int, Optional[datetime], PurePosixPath]],
|
|
metadata_result: Optional[List[Tuple[str, str, int, Optional[datetime], PurePosixPath]]] = None,
|
|
) -> Tuple[List[Dict[str, object]], Optional[Set[str]]]:
|
|
"""基础分支浅层探测:补扫本地缺失的直接子目录,并按规则自动加入临时分支。"""
|
|
discovered: List[Dict[str, object]] = []
|
|
remote_child_names: Set[str] = set()
|
|
try:
|
|
items = await driver.list_files(str(base_branch.get("parent_id") or ""))
|
|
except Exception as err:
|
|
if self._logger:
|
|
self._logger.warning(f"STRM基础分支探测失败: {base_branch.get('path')} - {err}")
|
|
return discovered, None
|
|
|
|
base_relative_text = str(base_branch.get("relative_path") or "").strip("/")
|
|
base_relative = PurePosixPath(base_relative_text) if base_relative_text else PurePosixPath()
|
|
base_path = str(base_branch.get("path") or "").rstrip("/")
|
|
expires_at = (datetime.now() + timedelta(days=30)).isoformat()
|
|
|
|
await self._collect_files_from_items(
|
|
driver=driver,
|
|
files=items,
|
|
base_posix_path=base_posix_path,
|
|
recursive=False,
|
|
extensions=media_extensions,
|
|
metadata_extensions=metadata_extensions,
|
|
metadata_max_size_bytes=metadata_max_size_bytes,
|
|
min_media_size_bytes=min_media_size_bytes,
|
|
metadata_parent_enabled=metadata_parent_enabled,
|
|
exclude_dir_rules=exclude_dir_rules,
|
|
exclude_file_rules=exclude_file_rules,
|
|
relative_parts=base_relative,
|
|
result=result,
|
|
metadata_result=metadata_result,
|
|
task=task,
|
|
)
|
|
|
|
for item in items:
|
|
if not getattr(item, "is_dir", False):
|
|
continue
|
|
child_id = str(item.id)
|
|
child_name = self._sanitize_path_component(
|
|
str(item.name),
|
|
kind="dir",
|
|
file_id=child_id,
|
|
parent_path=str(base_relative),
|
|
)
|
|
remote_child_names.add(child_name)
|
|
if child_id in existing_parent_ids:
|
|
continue
|
|
|
|
child_relative = base_relative / child_name
|
|
child_path = f"{base_path}/{child_name}" if base_path else f"/{child_name}"
|
|
local_child_root = task_root / child_relative
|
|
has_local_strm = local_child_root.exists() and any(local_child_root.rglob("*.strm"))
|
|
if has_local_strm:
|
|
continue
|
|
|
|
branch = {
|
|
"id": None,
|
|
"task_id": task.id,
|
|
"account_id": task.account_id,
|
|
"parent_id": child_id,
|
|
"path": child_path,
|
|
"relative_path": str(child_relative),
|
|
"recursive": 1,
|
|
"retention_days": 0,
|
|
"expires_at": None,
|
|
"branch_type": "discovered_child",
|
|
"source": "auto",
|
|
"status": "running",
|
|
}
|
|
|
|
if not await self._should_auto_add_temporary_branch(driver, child_id, media_extensions):
|
|
if self._logger:
|
|
self._logger.debug(f"STRM自动临时分支跳过非剧集目录: {child_path}")
|
|
discovered.append(branch)
|
|
continue
|
|
try:
|
|
branch_id = await db.create_strm_sync_branch(
|
|
task_id=task.id,
|
|
account_id=task.account_id,
|
|
parent_id=child_id,
|
|
path=child_path,
|
|
relative_path=str(child_relative),
|
|
recursive=True,
|
|
retention_days=30,
|
|
expires_at=expires_at,
|
|
branch_type="temporary",
|
|
source="auto",
|
|
status="running",
|
|
)
|
|
except Exception as err:
|
|
if self._logger:
|
|
self._logger.debug(f"STRM自动临时分支创建跳过: {child_path} - {err}")
|
|
discovered.append(branch)
|
|
continue
|
|
|
|
existing_parent_ids.add(child_id)
|
|
branch.update({
|
|
"id": branch_id,
|
|
"retention_days": 30,
|
|
"expires_at": expires_at,
|
|
"branch_type": "temporary",
|
|
})
|
|
discovered.append(branch)
|
|
if self._logger:
|
|
self._logger.info(f"STRM自动加入临时分支: {child_path}")
|
|
|
|
return discovered, remote_child_names
|
|
|
|
def _first_child_name_under_base(
|
|
self,
|
|
relative: PurePosixPath,
|
|
base_relative: PurePosixPath,
|
|
) -> Optional[str]:
|
|
relative_text = str(relative).strip("/")
|
|
base_text = str(base_relative).strip("/")
|
|
if base_text in {"", "."}:
|
|
suffix = relative_text
|
|
elif relative_text == base_text:
|
|
return None
|
|
elif relative_text.startswith(f"{base_text}/"):
|
|
suffix = relative_text[len(base_text) + 1:]
|
|
else:
|
|
return None
|
|
child_name = suffix.split("/", 1)[0].strip()
|
|
return child_name or None
|
|
|
|
def _cleanup_missing_base_child_dirs(
|
|
self,
|
|
task_root: Path,
|
|
base_relative: PurePosixPath,
|
|
remote_child_names: Set[str],
|
|
) -> int:
|
|
base_text = str(base_relative).strip("/")
|
|
local_base = task_root if base_text in {"", "."} else task_root / base_relative
|
|
if not local_base.exists() or not local_base.is_dir():
|
|
return 0
|
|
|
|
deleted = 0
|
|
for child in local_base.iterdir():
|
|
if not child.is_dir() or child.name in remote_child_names:
|
|
continue
|
|
strm_count = sum(1 for _ in child.rglob("*.strm"))
|
|
try:
|
|
shutil.rmtree(child)
|
|
deleted += strm_count
|
|
if self._logger:
|
|
self._logger.info(f"STRM分支清理远端已删除目录: {child}")
|
|
except OSError as err:
|
|
if self._logger:
|
|
self._logger.warning(f"STRM分支目录清理失败: {child} - {err}")
|
|
return deleted
|
|
|
|
# CDN 直链下载并发上限(115/夸克的 CDN 不走风控 API,多线程下载等同 IDM/Emby 行为)
|
|
_METADATA_CDN_CONCURRENCY = 3
|
|
|
|
async def _sync_metadata_files(
|
|
self,
|
|
driver,
|
|
task_root: Path,
|
|
metadata_items: List[Tuple[str, str, int, Optional[datetime], PurePosixPath]],
|
|
concurrency: int,
|
|
) -> int:
|
|
"""元数据(nfo / 海报 / 字幕)下载:
|
|
|
|
- 生产者(1 协程):串行调 resolve_download,节流由驱动层 wait_for_request_interval 统一处理;
|
|
- 消费者(N 协程):并发 HTTP GET CDN 直链写盘,**CDN 不风控**,能显著降低总耗时;
|
|
- 生产者阶段按 file_id 去重,相同 file_id 只 resolve + 下载一次,写到所有 target 路径。
|
|
|
|
参数 `concurrency` 仅影响 CDN 下载侧;resolve_download 永远串行。
|
|
"""
|
|
if not metadata_items:
|
|
return 0
|
|
|
|
# 1. 预聚合:同一 file_id 只下载一次,写到所有 target 路径
|
|
items_by_file_id: Dict[str, Dict[str, Any]] = {}
|
|
seen_paths: Set[str] = set()
|
|
for file_id, file_name, _size, _modified, relative_dir in metadata_items:
|
|
file_id = str(file_id)
|
|
safe_name = self._safe_child_name(file_name)
|
|
local_abs_path = task_root / relative_dir / safe_name
|
|
if self._strm_path_has_oversized_component(local_abs_path):
|
|
if self._logger:
|
|
self._logger.warning(
|
|
f"STRM元数据跳过(路径分量超过{_STRM_MAX_COMPONENT_BYTES}字节): {file_name}"
|
|
)
|
|
continue
|
|
local_key = str(PurePosixPath(local_abs_path.relative_to(task_root)))
|
|
if local_key in seen_paths:
|
|
continue
|
|
seen_paths.add(local_key)
|
|
if local_abs_path.exists() and local_abs_path.stat().st_size > 0:
|
|
continue
|
|
try:
|
|
expected_size = int(_size or 0)
|
|
except (TypeError, ValueError):
|
|
expected_size = 0
|
|
group = items_by_file_id.setdefault(
|
|
file_id,
|
|
{"expected_size": expected_size, "targets": []},
|
|
)
|
|
if expected_size > 0 and int(group.get("expected_size") or 0) <= 0:
|
|
group["expected_size"] = expected_size
|
|
group["targets"].append((local_abs_path, safe_name))
|
|
|
|
if not items_by_file_id:
|
|
if self._logger:
|
|
self._logger.debug(
|
|
f"STRM元数据同步跳过: 候选={len(metadata_items)} 全部已存在"
|
|
)
|
|
return 0
|
|
|
|
created_count = 0
|
|
failed_count = 0
|
|
cdn_concurrency = max(1, min(int(self._METADATA_CDN_CONCURRENCY), 8))
|
|
timeout = aiohttp.ClientTimeout(total=300, connect=30, sock_read=120)
|
|
download_queue: asyncio.Queue = asyncio.Queue(maxsize=cdn_concurrency * 2)
|
|
|
|
connector = aiohttp.TCPConnector(
|
|
limit=cdn_concurrency * 2,
|
|
limit_per_host=cdn_concurrency,
|
|
force_close=True,
|
|
enable_cleanup_closed=True,
|
|
)
|
|
|
|
async with aiohttp.ClientSession(
|
|
timeout=timeout,
|
|
cookie_jar=aiohttp.DummyCookieJar(),
|
|
connector=connector,
|
|
) as session:
|
|
class _TransientMetadataDownloadError(Exception):
|
|
pass
|
|
|
|
async def fetch_metadata_content(
|
|
file_id: str,
|
|
download_url: str,
|
|
headers: dict,
|
|
expected_size: int = 0,
|
|
) -> bytes:
|
|
"""下载单个元数据文件。
|
|
|
|
小型 nfo/字幕/海报偶发被 CDN 断开时,重试同一个直链通常就能恢复;
|
|
这里不并发重新 resolve,避免把 API 请求节流绕乱。
|
|
"""
|
|
last_error: Optional[Exception] = None
|
|
transient_errors = (
|
|
aiohttp.ServerDisconnectedError,
|
|
aiohttp.ClientPayloadError,
|
|
aiohttp.ClientOSError,
|
|
asyncio.TimeoutError,
|
|
_TransientMetadataDownloadError,
|
|
)
|
|
base_headers = dict(headers or {})
|
|
base_headers.setdefault("Accept", "*/*")
|
|
base_headers.setdefault(
|
|
"User-Agent",
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
)
|
|
base_headers["Accept-Encoding"] = "identity"
|
|
base_headers["Connection"] = "close"
|
|
base_headers.pop("Cache-Control", None)
|
|
base_headers.pop("Range", None)
|
|
expected_size = max(int(expected_size or 0), 0)
|
|
|
|
for attempt in range(3):
|
|
try:
|
|
async with session.get(download_url, headers=base_headers) as response:
|
|
if response.status in {500, 502, 503, 504}:
|
|
body = await response.text(errors="ignore")
|
|
raise _TransientMetadataDownloadError(
|
|
f"HTTP {response.status}: {body[:200]}"
|
|
)
|
|
if response.status >= 400:
|
|
body = await response.text(errors="ignore")
|
|
raise Exception(f"HTTP {response.status}: {body[:200]}")
|
|
|
|
chunks = bytearray()
|
|
async for chunk in response.content.iter_chunked(65536):
|
|
if chunk:
|
|
chunks.extend(chunk)
|
|
|
|
expected_length = (response.headers.get("Content-Length") or "").strip()
|
|
if expected_length.isdigit() and len(chunks) != int(expected_length):
|
|
raise aiohttp.ClientPayloadError(
|
|
f"Content-Length不一致: expected={expected_length}, got={len(chunks)}"
|
|
)
|
|
if expected_size > 0 and len(chunks) != expected_size:
|
|
raise aiohttp.ClientPayloadError(
|
|
f"文件大小不一致: expected={expected_size}, got={len(chunks)}"
|
|
)
|
|
return bytes(chunks)
|
|
except transient_errors as err:
|
|
last_error = err
|
|
if attempt >= 2:
|
|
break
|
|
await asyncio.sleep(0.5 * (attempt + 1))
|
|
|
|
raise last_error or Exception("元数据下载失败")
|
|
|
|
async def resolver():
|
|
"""串行生产者:resolve_download 的节流由驱动层 wait_for_request_interval 统一处理。"""
|
|
nonlocal failed_count
|
|
try:
|
|
for file_id, group in items_by_file_id.items():
|
|
targets = group.get("targets") or []
|
|
expected_size = int(group.get("expected_size") or 0)
|
|
try:
|
|
download = await resolve_download(driver, file_id, "")
|
|
if not download.download_url:
|
|
raise Exception("无下载地址")
|
|
if expected_size <= 0 and int(download.file_size or 0) > 0:
|
|
expected_size = int(download.file_size or 0)
|
|
headers = await build_upstream_download_headers(
|
|
driver,
|
|
file_id,
|
|
"",
|
|
prefer_identity=True,
|
|
)
|
|
except Exception as err:
|
|
failed_count += len(targets)
|
|
if self._logger:
|
|
first_name = targets[0][1] if targets else file_id
|
|
self._logger.warning(
|
|
f"STRM元数据获取直链失败: {first_name} (file_id={file_id}) - {err}"
|
|
)
|
|
continue
|
|
await download_queue.put((file_id, targets, download.download_url, headers, expected_size))
|
|
finally:
|
|
# 哨兵通知所有消费者退出
|
|
for _ in range(cdn_concurrency):
|
|
await download_queue.put(None)
|
|
|
|
async def downloader():
|
|
"""并发消费者:拿到直链就 HTTP GET CDN 写盘,CDN 侧不限速。"""
|
|
nonlocal created_count, failed_count
|
|
while True:
|
|
msg = await download_queue.get()
|
|
if msg is None:
|
|
return
|
|
file_id, targets, download_url, headers, expected_size = msg
|
|
primary_path, primary_name = targets[0]
|
|
tmp_path = primary_path.with_name(primary_path.name + ".tmp")
|
|
try:
|
|
content = await fetch_metadata_content(file_id, download_url, headers, expected_size)
|
|
|
|
primary_path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp_path.write_bytes(content)
|
|
tmp_path.replace(primary_path)
|
|
created_count += 1
|
|
|
|
# 同 file_id 的其它 target 路径,复用已下载的 content
|
|
for extra_path, extra_name in targets[1:]:
|
|
try:
|
|
extra_path.parent.mkdir(parents=True, exist_ok=True)
|
|
extra_tmp = extra_path.with_name(extra_path.name + ".tmp")
|
|
extra_tmp.write_bytes(content)
|
|
extra_tmp.replace(extra_path)
|
|
created_count += 1
|
|
except (OSError, ValueError) as extra_err:
|
|
failed_count += 1
|
|
self._log_strm_path_error(
|
|
extra_err,
|
|
action="metadata_write",
|
|
file_id=file_id,
|
|
file_name=extra_name,
|
|
local_path=extra_path,
|
|
)
|
|
except (OSError, ValueError) as err:
|
|
failed_count += len(targets)
|
|
try:
|
|
if tmp_path.exists():
|
|
tmp_path.unlink(missing_ok=True)
|
|
except Exception:
|
|
pass
|
|
self._log_strm_path_error(
|
|
err,
|
|
action="metadata_write",
|
|
file_id=file_id,
|
|
file_name=primary_name,
|
|
local_path=primary_path,
|
|
)
|
|
except Exception as err:
|
|
failed_count += len(targets)
|
|
try:
|
|
if tmp_path.exists():
|
|
tmp_path.unlink(missing_ok=True)
|
|
except Exception:
|
|
pass
|
|
if self._logger:
|
|
self._logger.warning(
|
|
f"STRM元数据下载失败: {primary_name} (file_id={file_id}) - {err}"
|
|
)
|
|
|
|
await asyncio.gather(
|
|
resolver(),
|
|
*[downloader() for _ in range(cdn_concurrency)],
|
|
)
|
|
|
|
if self._logger:
|
|
message = (
|
|
f"STRM元数据同步完成: 候选={len(metadata_items)} 实下载={len(items_by_file_id)} "
|
|
f"新增={created_count} 失败={failed_count}"
|
|
)
|
|
if failed_count:
|
|
self._logger.warning(message)
|
|
else:
|
|
self._logger.debug(message)
|
|
return created_count
|
|
|
|
async def _auto_pause_task(
|
|
self,
|
|
task: StrmSyncTask,
|
|
*,
|
|
reason: str,
|
|
message: Optional[str],
|
|
):
|
|
"""自动暂停并落库。reason: account_disabled / auth_failure / user。"""
|
|
task.status = "paused"
|
|
task.paused_reason = reason
|
|
task.next_run_time = None
|
|
task.last_scan = datetime.now()
|
|
task.last_scan_status = "error"
|
|
task.error_message = message
|
|
try:
|
|
await db.update_strm_sync_task(
|
|
task.id,
|
|
status="paused",
|
|
paused_reason=reason,
|
|
last_scan=task.last_scan.isoformat(),
|
|
last_scan_status="error",
|
|
error_message=message,
|
|
)
|
|
except Exception as db_err:
|
|
if self._logger:
|
|
self._logger.error(
|
|
f"写入 STRM 任务自动暂停状态失败: task={task.id} reason={reason} err={db_err}"
|
|
)
|
|
|
|
_AUTH_ERROR_KEYWORDS = (
|
|
"认证", "token", "401", "403", "cookie",
|
|
"登录", "permission", "unauthorized",
|
|
)
|
|
|
|
def _looks_like_auth_error(self, message: str) -> bool:
|
|
if not message:
|
|
return False
|
|
lowered = message.lower()
|
|
return any(kw in lowered for kw in self._AUTH_ERROR_KEYWORDS)
|
|
|
|
async def _run_task(self, task_id: int, run_mode: str = "auto"):
|
|
started_at = datetime.now()
|
|
delay_token = None
|
|
try:
|
|
task = self._tasks.get(task_id)
|
|
if not task:
|
|
return
|
|
|
|
# 账号不存在 / 已禁用 / 认证失效:本次直接自动暂停,不要继续跑打爆 API
|
|
account = await db.get_account(task.account_id)
|
|
if not account:
|
|
await self._auto_pause_task(
|
|
task, reason="account_disabled", message="关联的账号已删除"
|
|
)
|
|
return
|
|
if not account.get("is_active", True):
|
|
await self._auto_pause_task(
|
|
task, reason="account_disabled", message="关联的账号已禁用"
|
|
)
|
|
return
|
|
account_config = account.get("config", {}) or {}
|
|
if isinstance(account_config, str):
|
|
try:
|
|
import json as _json
|
|
account_config = _json.loads(account_config)
|
|
except Exception:
|
|
account_config = {}
|
|
if account_config.get("auth_status") == "failed":
|
|
await self._auto_pause_task(
|
|
task,
|
|
reason="auth_failure",
|
|
message="关联的账号认证已失效,请重新配置账号认证信息",
|
|
)
|
|
return
|
|
|
|
# 初始化进度字段
|
|
task.scanned_dirs = 0
|
|
task.scanned_files = 0
|
|
task.started_at = started_at
|
|
|
|
scan_interval, concurrency, task_concurrency_limit = await self._get_runtime_settings()
|
|
task.scan_interval = scan_interval
|
|
task.concurrency = concurrency
|
|
self._task_concurrency_limit = task_concurrency_limit
|
|
base_url = await self._get_strm_base_url()
|
|
link_format = await self._get_strm_link_format()
|
|
if not base_url:
|
|
duration_ms = max(int((datetime.now() - started_at).total_seconds() * 1000), 0)
|
|
await db.update_strm_sync_task(
|
|
task_id,
|
|
last_created_count=0,
|
|
last_updated_count=0,
|
|
last_deleted_count=0,
|
|
last_duration_ms=duration_ms,
|
|
last_scan=datetime.now().isoformat(),
|
|
last_scan_status="error",
|
|
error_message="STRM播放地址基址未设置",
|
|
)
|
|
task.last_created_count = 0
|
|
task.last_updated_count = 0
|
|
task.last_deleted_count = 0
|
|
task.last_duration_ms = duration_ms
|
|
task.last_scan = datetime.now()
|
|
task.last_scan_status = "error"
|
|
task.error_message = "STRM播放地址基址未设置"
|
|
task.next_run_time = datetime.now() + timedelta(minutes=scan_interval)
|
|
return
|
|
|
|
token = await self._get_strm_token()
|
|
default_extensions_text = await self._get_str_setting(
|
|
"strm_default_extensions",
|
|
DEFAULT_MEDIA_EXTENSIONS,
|
|
)
|
|
extensions = self._parse_extensions(default_extensions_text)
|
|
exclude_dir_rules = self._parse_keyword_rules(task.exclude_dir_keywords)
|
|
exclude_file_rules = self._parse_keyword_rules(task.exclude_file_keywords)
|
|
signature_enabled = await self._get_bool_setting("strm_signature_enabled", False)
|
|
min_media_size_mb = await self._get_int_setting("strm_min_file_size_mb", 0, 0, 10240)
|
|
min_media_size_bytes = min_media_size_mb * 1024 * 1024
|
|
metadata_extensions_text = await self._get_str_setting(
|
|
"strm_metadata_extensions",
|
|
DEFAULT_METADATA_EXTENSIONS,
|
|
)
|
|
metadata_extensions = self._parse_optional_extensions(metadata_extensions_text)
|
|
metadata_max_size_mb = await self._get_int_setting("strm_metadata_max_size_mb", 10, 1, 1024)
|
|
metadata_max_size_bytes = metadata_max_size_mb * 1024 * 1024
|
|
metadata_parent_enabled = await self._get_bool_setting("strm_metadata_parent_enabled", True)
|
|
conflict_policy = self._normalize_conflict_policy(
|
|
await self._get_str_setting("strm_conflict_policy", "size_desc")
|
|
)
|
|
|
|
driver = await get_account_driver(task.account_id)
|
|
if not driver:
|
|
duration_ms = max(int((datetime.now() - started_at).total_seconds() * 1000), 0)
|
|
await db.update_strm_sync_task(
|
|
task_id,
|
|
last_created_count=0,
|
|
last_updated_count=0,
|
|
last_deleted_count=0,
|
|
last_duration_ms=duration_ms,
|
|
last_scan=datetime.now().isoformat(),
|
|
last_scan_status="error",
|
|
error_message="驱动实例不可用",
|
|
)
|
|
task.last_created_count = 0
|
|
task.last_updated_count = 0
|
|
task.last_deleted_count = 0
|
|
task.last_duration_ms = duration_ms
|
|
task.last_scan = datetime.now()
|
|
task.last_scan_status = "error"
|
|
task.error_message = "驱动实例不可用"
|
|
task.next_run_time = datetime.now() + timedelta(minutes=scan_interval)
|
|
return
|
|
|
|
delay_token = _extra_api_delay.set(task.api_interval)
|
|
|
|
self._strm_root.mkdir(parents=True, exist_ok=True)
|
|
task_folder = self._sanitize_task_folder(task.name)
|
|
task_root = self._get_task_root(task.name)
|
|
task_root.mkdir(parents=True, exist_ok=True)
|
|
deleted_count = 0
|
|
use_branch_check = run_mode == "branch" or (run_mode == "auto" and task.branch_check_enabled)
|
|
branches: List[Dict[str, object]] = []
|
|
if use_branch_check:
|
|
try:
|
|
await db.delete_expired_strm_sync_branches(task.id)
|
|
except Exception:
|
|
pass
|
|
branches = await db.get_strm_sync_branches(task.id, only_active=True)
|
|
if not branches:
|
|
if self._logger:
|
|
self._logger.info(f"STRM分支检查跳过全量扫描: {task.name} 未配置有效分支")
|
|
|
|
if task.scan_mode == "full_sync" and not use_branch_check:
|
|
for file_path in task_root.rglob("*.strm"):
|
|
try:
|
|
file_path.unlink(missing_ok=True)
|
|
deleted_count += 1
|
|
except Exception:
|
|
pass
|
|
|
|
collected: List[Tuple[str, str, str, int, Optional[datetime], PurePosixPath]] = []
|
|
metadata_collected: List[Tuple[str, str, int, Optional[datetime], PurePosixPath]] = []
|
|
base_posix_path = "/" + task.path.strip("/")
|
|
base_posix_path = "/" if base_posix_path == "/" else base_posix_path.rstrip("/")
|
|
|
|
cleanup_scopes: List[Tuple[PurePosixPath, bool]] = []
|
|
base_remote_child_names: Dict[PurePosixPath, Set[str]] = {}
|
|
if use_branch_check:
|
|
existing_parent_ids = {str(branch.get("parent_id") or "") for branch in branches}
|
|
scan_branches = list(branches)
|
|
for branch in list(branches):
|
|
if str(branch.get("branch_type") or "temporary") != "base":
|
|
continue
|
|
relative_text = str(branch.get("relative_path") or "").strip("/")
|
|
branch_relative = PurePosixPath(relative_text) if relative_text else PurePosixPath()
|
|
branch_path = str(branch.get("path") or "").strip("/")
|
|
branch_base_posix_path = "/" + branch_path if branch_path else base_posix_path
|
|
branch_base_posix_path = "/" if branch_base_posix_path == "/" else branch_base_posix_path.rstrip("/")
|
|
discovered, remote_child_names = await self._discover_missing_temporary_branches(
|
|
driver,
|
|
task,
|
|
task_root,
|
|
branch,
|
|
existing_parent_ids,
|
|
extensions,
|
|
metadata_extensions,
|
|
metadata_max_size_bytes,
|
|
min_media_size_bytes,
|
|
metadata_parent_enabled,
|
|
exclude_dir_rules,
|
|
exclude_file_rules,
|
|
branch_base_posix_path,
|
|
collected,
|
|
metadata_collected if task.sync_metadata and metadata_extensions else None,
|
|
)
|
|
if remote_child_names is not None:
|
|
base_remote_child_names[branch_relative] = remote_child_names
|
|
scan_branches.extend(discovered)
|
|
|
|
filtered_branches: List[Dict[str, object]] = []
|
|
for branch in scan_branches:
|
|
branch_type = str(branch.get("branch_type") or "temporary")
|
|
if branch_type != "base":
|
|
relative_text = str(branch.get("relative_path") or "").strip("/")
|
|
branch_relative = PurePosixPath(relative_text) if relative_text else PurePosixPath()
|
|
missing_under_base = False
|
|
for base_relative, remote_child_names in base_remote_child_names.items():
|
|
child_name = self._first_child_name_under_base(branch_relative, base_relative)
|
|
if child_name is not None and child_name not in remote_child_names:
|
|
missing_under_base = True
|
|
branch_id = branch.get("id")
|
|
if branch_id:
|
|
try:
|
|
await db.delete_strm_sync_branch(int(branch_id))
|
|
except Exception:
|
|
pass
|
|
if self._logger:
|
|
self._logger.info(f"STRM移除远端已删除目录的临时分支: {branch.get('path')}")
|
|
break
|
|
if missing_under_base:
|
|
continue
|
|
filtered_branches.append(branch)
|
|
branches = filtered_branches
|
|
|
|
for branch in branches:
|
|
relative_text = str(branch.get("relative_path") or "").strip("/")
|
|
branch_relative = PurePosixPath(relative_text) if relative_text else PurePosixPath()
|
|
branch_type = str(branch.get("branch_type") or "temporary")
|
|
branch_recursive = bool(branch.get("recursive"))
|
|
if branch_type == "base":
|
|
branch_recursive = False
|
|
cleanup_scopes.append((branch_relative, branch_recursive))
|
|
if branch_type == "base":
|
|
branch_id = branch.get("id")
|
|
if branch_id:
|
|
try:
|
|
await db.update_strm_sync_branch(
|
|
int(branch_id),
|
|
last_scan=datetime.now().isoformat(),
|
|
last_scan_status="success",
|
|
error_message=None,
|
|
)
|
|
except Exception:
|
|
pass
|
|
continue
|
|
branch_path = str(branch.get("path") or "").strip("/")
|
|
branch_base_posix_path = "/" + branch_path if branch_path else base_posix_path
|
|
branch_base_posix_path = "/" if branch_base_posix_path == "/" else branch_base_posix_path.rstrip("/")
|
|
await self._collect_files(
|
|
driver=driver,
|
|
parent_id=str(branch.get("parent_id") or ""),
|
|
base_posix_path=branch_base_posix_path,
|
|
recursive=branch_recursive,
|
|
extensions=extensions,
|
|
metadata_extensions=metadata_extensions,
|
|
metadata_max_size_bytes=metadata_max_size_bytes,
|
|
min_media_size_bytes=min_media_size_bytes,
|
|
metadata_parent_enabled=metadata_parent_enabled,
|
|
exclude_dir_rules=exclude_dir_rules,
|
|
exclude_file_rules=exclude_file_rules,
|
|
relative_parts=branch_relative,
|
|
result=collected,
|
|
metadata_result=metadata_collected if task.sync_metadata and metadata_extensions else None,
|
|
task=task,
|
|
)
|
|
branch_id = branch.get("id")
|
|
if branch_id:
|
|
try:
|
|
await db.update_strm_sync_branch(
|
|
int(branch_id),
|
|
last_scan=datetime.now().isoformat(),
|
|
last_scan_status="success",
|
|
error_message=None,
|
|
)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
await self._collect_files(
|
|
driver=driver,
|
|
parent_id=task.parent_id,
|
|
base_posix_path=base_posix_path,
|
|
recursive=task.recursive,
|
|
extensions=extensions,
|
|
metadata_extensions=metadata_extensions,
|
|
metadata_max_size_bytes=metadata_max_size_bytes,
|
|
min_media_size_bytes=min_media_size_bytes,
|
|
metadata_parent_enabled=metadata_parent_enabled,
|
|
exclude_dir_rules=exclude_dir_rules,
|
|
exclude_file_rules=exclude_file_rules,
|
|
relative_parts=PurePosixPath(),
|
|
result=collected,
|
|
metadata_result=metadata_collected if task.sync_metadata and metadata_extensions else None,
|
|
task=task,
|
|
)
|
|
|
|
collected, conflict_skipped_count = self._select_conflict_winners(collected, conflict_policy)
|
|
|
|
semaphore = asyncio.Semaphore(max(1, min(int(concurrency), 20)))
|
|
created_count = 0
|
|
updated_count = 0
|
|
desired_local_rel_paths: Set[str] = set()
|
|
|
|
async def handle_one(item: Tuple[str, str, str, int, Optional[datetime], PurePosixPath]):
|
|
nonlocal created_count, updated_count
|
|
file_id, file_name, remote_path, size, modified, relative_dir = item
|
|
|
|
local_rel_path = self._build_local_rel_path(task_folder, relative_dir, file_name)
|
|
local_rel_path_text = str(PurePosixPath(local_rel_path))
|
|
local_abs_path = task_root / PurePosixPath(local_rel_path).relative_to(task_folder)
|
|
if self._strm_path_has_oversized_component(local_abs_path):
|
|
if self._logger:
|
|
self._logger.warning(
|
|
f"STRM跳过(路径分量超过{_STRM_MAX_COMPONENT_BYTES}字节): {file_name}"
|
|
)
|
|
return
|
|
desired_local_rel_paths.add(local_rel_path_text)
|
|
try:
|
|
local_abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
except (OSError, ValueError) as err:
|
|
self._log_strm_path_error(
|
|
err,
|
|
action="mkdir",
|
|
file_id=file_id,
|
|
file_name=file_name,
|
|
relative_dir=relative_dir,
|
|
remote_path=remote_path,
|
|
local_path=local_abs_path,
|
|
)
|
|
return
|
|
local_exists = local_abs_path.exists()
|
|
migrated_legacy = False
|
|
if not local_exists:
|
|
legacy_rel_path = self._build_legacy_local_rel_path(task_folder, relative_dir, file_name)
|
|
if legacy_rel_path and legacy_rel_path != local_rel_path:
|
|
legacy_abs_path = task_root / PurePosixPath(legacy_rel_path).relative_to(task_folder)
|
|
if legacy_abs_path.exists():
|
|
try:
|
|
legacy_abs_path.replace(local_abs_path)
|
|
local_exists = True
|
|
migrated_legacy = True
|
|
except OSError as err:
|
|
if self._logger:
|
|
self._logger.warning(f"STRM旧格式迁移失败: {legacy_abs_path.name} - {err}")
|
|
|
|
if task.scan_mode == "incremental_missing":
|
|
if local_exists and not migrated_legacy:
|
|
return
|
|
url = self._build_play_url(base_url, task.account_id, file_id, token, signature_enabled, link_format)
|
|
if local_exists:
|
|
try:
|
|
if local_abs_path.read_text(encoding="utf-8").strip() == url:
|
|
return
|
|
except Exception:
|
|
pass
|
|
pending_update = True
|
|
else:
|
|
pending_update = False
|
|
try:
|
|
local_abs_path.write_text(url + "\n", encoding="utf-8")
|
|
if pending_update:
|
|
updated_count += 1
|
|
else:
|
|
created_count += 1
|
|
except (OSError, ValueError) as err:
|
|
desired_local_rel_paths.discard(local_rel_path_text)
|
|
self._log_strm_path_error(
|
|
err,
|
|
action="write",
|
|
file_id=file_id,
|
|
file_name=file_name,
|
|
relative_dir=relative_dir,
|
|
remote_path=remote_path,
|
|
local_path=local_abs_path,
|
|
)
|
|
|
|
async def guarded(item):
|
|
async with semaphore:
|
|
await handle_one(item)
|
|
|
|
await asyncio.gather(*[guarded(item) for item in collected])
|
|
|
|
metadata_created_count = 0
|
|
if task.sync_metadata and self._logger:
|
|
self._logger.debug(
|
|
f"STRM元数据扫描完成: 候选={len(metadata_collected)} 扩展={';'.join(sorted(metadata_extensions)) or '-'}"
|
|
)
|
|
if task.sync_metadata and metadata_collected:
|
|
metadata_created_count = await self._sync_metadata_files(
|
|
driver,
|
|
task_root,
|
|
metadata_collected,
|
|
concurrency,
|
|
)
|
|
|
|
if task.scan_mode in {"incremental_update", "full_sync"}:
|
|
cleanup_roots = []
|
|
if use_branch_check:
|
|
cleanup_roots = [(task_root / scope, recursive) for scope, recursive in cleanup_scopes]
|
|
else:
|
|
cleanup_roots = [(task_root, True)]
|
|
for cleanup_root, recursive_cleanup in cleanup_roots:
|
|
if not cleanup_root.exists():
|
|
continue
|
|
file_iter = cleanup_root.rglob("*.strm") if recursive_cleanup else cleanup_root.glob("*.strm")
|
|
for file_path in file_iter:
|
|
rel = str(PurePosixPath(file_path.relative_to(self._strm_root)))
|
|
if rel not in desired_local_rel_paths:
|
|
try:
|
|
file_path.unlink(missing_ok=True)
|
|
deleted_count += 1
|
|
except Exception:
|
|
pass
|
|
if recursive_cleanup:
|
|
for directory in sorted(cleanup_root.rglob("*"), reverse=True):
|
|
if directory.is_dir():
|
|
try:
|
|
directory.rmdir()
|
|
except OSError:
|
|
pass
|
|
if use_branch_check:
|
|
for base_relative, remote_child_names in base_remote_child_names.items():
|
|
deleted_count += self._cleanup_missing_base_child_dirs(
|
|
task_root,
|
|
base_relative,
|
|
remote_child_names,
|
|
)
|
|
|
|
task.file_count = len(collected)
|
|
task.last_created_count = created_count
|
|
task.last_updated_count = updated_count
|
|
task.last_deleted_count = deleted_count
|
|
task.last_duration_ms = max(int((datetime.now() - started_at).total_seconds() * 1000), 0)
|
|
task.last_scan = datetime.now()
|
|
task.last_scan_status = "success"
|
|
task.error_message = None
|
|
task.next_run_time = datetime.now() + timedelta(minutes=scan_interval)
|
|
|
|
await db.update_strm_sync_task(
|
|
task_id,
|
|
file_count=task.file_count,
|
|
last_created_count=task.last_created_count,
|
|
last_updated_count=task.last_updated_count,
|
|
last_deleted_count=task.last_deleted_count,
|
|
last_duration_ms=task.last_duration_ms,
|
|
last_scan=task.last_scan.isoformat(),
|
|
last_scan_status=task.last_scan_status,
|
|
error_message=None,
|
|
)
|
|
|
|
self._logger.debug(
|
|
f"STRM任务完成: {task.name} mode={'branch' if use_branch_check else 'full'} account={task.account_id} path={task.path} files={task.file_count} created={created_count} updated={updated_count} deleted={deleted_count} skipped_conflict={conflict_skipped_count} metadata={metadata_created_count} duration_ms={task.last_duration_ms}"
|
|
)
|
|
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
except Exception as e:
|
|
failed_task = self._tasks.get(task_id)
|
|
if self._is_embedded_null_error(e):
|
|
self._logger.error(
|
|
f"STRM任务执行失败(路径含隐藏空字符): {task_id} {e} | "
|
|
f"task={getattr(failed_task, 'name', '-')} "
|
|
f"account={getattr(failed_task, 'account_id', '-')} "
|
|
f"path={getattr(failed_task, 'path', '-')}"
|
|
)
|
|
else:
|
|
self._logger.error(f"STRM任务执行失败: {task_id} {e}")
|
|
error_msg = str(e)
|
|
duration_ms_err = max(int((datetime.now() - started_at).total_seconds() * 1000), 0)
|
|
task = failed_task
|
|
auth_failure = self._looks_like_auth_error(error_msg)
|
|
|
|
if auth_failure and task is not None:
|
|
# 认证类错误:走自动暂停,等账号在 admin 那边被修好后由 resume_tasks_by_account 恢复
|
|
task.last_created_count = 0
|
|
task.last_updated_count = 0
|
|
task.last_deleted_count = 0
|
|
task.last_duration_ms = duration_ms_err
|
|
await self._auto_pause_task(
|
|
task,
|
|
reason="auth_failure",
|
|
message=f"认证错误,请检查账号配置: {error_msg[:200]}",
|
|
)
|
|
try:
|
|
await db.update_strm_sync_task(
|
|
task_id,
|
|
last_created_count=0,
|
|
last_updated_count=0,
|
|
last_deleted_count=0,
|
|
last_duration_ms=duration_ms_err,
|
|
)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
try:
|
|
await db.update_strm_sync_task(
|
|
task_id,
|
|
last_created_count=0,
|
|
last_updated_count=0,
|
|
last_deleted_count=0,
|
|
last_duration_ms=duration_ms_err,
|
|
last_scan=datetime.now().isoformat(),
|
|
last_scan_status="error",
|
|
error_message=error_msg,
|
|
)
|
|
except Exception:
|
|
pass
|
|
if task:
|
|
task.last_created_count = 0
|
|
task.last_updated_count = 0
|
|
task.last_deleted_count = 0
|
|
task.last_duration_ms = duration_ms_err
|
|
task.last_scan = datetime.now()
|
|
task.last_scan_status = "error"
|
|
task.error_message = error_msg
|
|
scan_interval, concurrency, task_concurrency_limit = await self._get_runtime_settings()
|
|
task.scan_interval = scan_interval
|
|
task.concurrency = concurrency
|
|
self._task_concurrency_limit = task_concurrency_limit
|
|
task.next_run_time = datetime.now() + timedelta(minutes=scan_interval)
|
|
finally:
|
|
if delay_token is not None:
|
|
_extra_api_delay.reset(delay_token)
|
|
self._running_tasks.discard(task_id)
|
|
if task_id in self._running_task_futures:
|
|
del self._running_task_futures[task_id]
|
|
task = self._tasks.get(task_id)
|
|
if task:
|
|
self._running_account_ids.discard(task.account_id)
|
|
|
|
def _parse_strm_play_url(self, value: str) -> Optional[Dict[str, Any]]:
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
return None
|
|
try:
|
|
parts = urllib.parse.urlsplit(text)
|
|
except Exception:
|
|
return None
|
|
path = parts.path or ""
|
|
|
|
v2_match = re.match(r"^/api/strm/v2/play/(\d+)/([^/]+)/t/([^/]+)(?:/s/([^/]+))?/?$", path)
|
|
if v2_match:
|
|
try:
|
|
file_id = decode_strm_file_key(v2_match.group(2))
|
|
except Exception:
|
|
return None
|
|
return {
|
|
"format": "v2",
|
|
"parts": parts,
|
|
"account_id": int(v2_match.group(1)),
|
|
"file_id": file_id,
|
|
"token": urllib.parse.unquote(v2_match.group(3) or ""),
|
|
"signature": v2_match.group(4) or "",
|
|
}
|
|
|
|
v1_match = re.match(r"^/api/strm/play/(\d+)/(.*)$", path)
|
|
if v1_match:
|
|
pairs = urllib.parse.parse_qsl(parts.query, keep_blank_values=True)
|
|
token = ""
|
|
signature = ""
|
|
for key, item_value in pairs:
|
|
if key == "token":
|
|
token = item_value
|
|
elif key == "sign":
|
|
signature = item_value
|
|
return {
|
|
"format": "v1",
|
|
"parts": parts,
|
|
"account_id": int(v1_match.group(1)),
|
|
"file_id": urllib.parse.unquote(v1_match.group(2) or ""),
|
|
"token": token,
|
|
"signature": signature,
|
|
"query_pairs": pairs,
|
|
}
|
|
return None
|
|
|
|
def _replace_url_path(self, parts: urllib.parse.SplitResult, new_path: str, query: str = "") -> str:
|
|
return urllib.parse.urlunsplit((
|
|
parts.scheme,
|
|
parts.netloc,
|
|
new_path,
|
|
query,
|
|
parts.fragment,
|
|
))
|
|
|
|
def _replace_v2_token_in_url(self, parsed: Dict[str, Any], new_token: str) -> str:
|
|
parts = parsed["parts"]
|
|
signature_enabled = bool(parsed.get("signature"))
|
|
new_path = build_strm_v2_play_path(
|
|
int(parsed["account_id"]),
|
|
str(parsed["file_id"]),
|
|
new_token,
|
|
signature_enabled,
|
|
)
|
|
return self._replace_url_path(parts, new_path, parts.query)
|
|
|
|
def _build_full_play_url(
|
|
self,
|
|
base_url: str,
|
|
account_id: int,
|
|
file_id: str,
|
|
token: str,
|
|
signature_enabled: bool,
|
|
link_format: str,
|
|
) -> str:
|
|
return self._build_play_url(
|
|
base_url.rstrip("/"),
|
|
account_id,
|
|
file_id,
|
|
token,
|
|
signature_enabled,
|
|
link_format,
|
|
)
|
|
|
|
async def replace_strm_domain(self, new_base_url: str) -> Dict[str, int]:
|
|
base = (new_base_url or "").strip().rstrip("/")
|
|
if not base:
|
|
raise ValueError("new_base_url required")
|
|
self._strm_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
total = 0
|
|
updated = 0
|
|
for file_path in self._strm_root.rglob("*.strm"):
|
|
total += 1
|
|
try:
|
|
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
lines = content.splitlines()
|
|
if not lines:
|
|
continue
|
|
first = lines[0].strip()
|
|
if not first:
|
|
continue
|
|
replaced = re.sub(r"^https?://[^/]+", base, first)
|
|
if replaced != first:
|
|
lines[0] = replaced
|
|
file_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
updated += 1
|
|
except Exception:
|
|
continue
|
|
|
|
await config_manager.set_async("strm_base_url", base)
|
|
return {"total": total, "updated": updated}
|
|
|
|
async def replace_strm_token(self, old_token: str, new_token: str) -> Dict[str, int]:
|
|
old_value = str(old_token or "").strip()
|
|
new_value = str(new_token or "").strip()
|
|
if not new_value:
|
|
raise ValueError("new_token required")
|
|
self._strm_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
total = 0
|
|
matched = 0
|
|
updated = 0
|
|
for file_path in self._strm_root.rglob("*.strm"):
|
|
total += 1
|
|
try:
|
|
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
lines = content.splitlines()
|
|
if not lines:
|
|
continue
|
|
first = lines[0].strip()
|
|
if not first:
|
|
continue
|
|
|
|
parsed = self._parse_strm_play_url(first)
|
|
if not parsed:
|
|
continue
|
|
|
|
changed = False
|
|
if parsed["format"] == "v2":
|
|
if old_value and parsed.get("token") == old_value:
|
|
matched += 1
|
|
replaced = self._replace_v2_token_in_url(parsed, new_value)
|
|
changed = replaced != first
|
|
else:
|
|
parts = parsed["parts"]
|
|
pairs = parsed.get("query_pairs") or []
|
|
next_pairs = []
|
|
for key, value in pairs:
|
|
if key == "token" and old_value and value == old_value:
|
|
matched += 1
|
|
if value != new_value:
|
|
value = new_value
|
|
changed = True
|
|
next_pairs.append((key, value))
|
|
|
|
if changed:
|
|
replaced = urllib.parse.urlunsplit((
|
|
parts.scheme,
|
|
parts.netloc,
|
|
parts.path,
|
|
urllib.parse.urlencode(next_pairs),
|
|
parts.fragment,
|
|
))
|
|
|
|
if changed:
|
|
lines[0] = replaced
|
|
file_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
updated += 1
|
|
except Exception:
|
|
continue
|
|
|
|
return {"total": total, "matched": matched, "updated": updated}
|
|
|
|
async def replace_strm_link_format(self, link_format: str) -> Dict[str, int]:
|
|
target_format = str(link_format or "v1").strip().lower()
|
|
if target_format not in STRM_LINK_FORMATS:
|
|
raise ValueError("unsupported strm link format")
|
|
default_base_url = await self._get_strm_base_url()
|
|
default_token = await self._get_strm_token()
|
|
self._strm_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
total = 0
|
|
matched = 0
|
|
updated = 0
|
|
for file_path in self._strm_root.rglob("*.strm"):
|
|
total += 1
|
|
try:
|
|
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
lines = content.splitlines()
|
|
if not lines:
|
|
continue
|
|
first = lines[0].strip()
|
|
parsed = self._parse_strm_play_url(first)
|
|
if not parsed:
|
|
continue
|
|
matched += 1
|
|
parts = parsed["parts"]
|
|
base_url = (
|
|
f"{parts.scheme}://{parts.netloc}".rstrip("/")
|
|
if parts.scheme and parts.netloc
|
|
else default_base_url
|
|
)
|
|
token = str(parsed.get("token") or "").strip() or default_token
|
|
signature_enabled = bool(str(parsed.get("signature") or "").strip())
|
|
replaced = self._build_full_play_url(
|
|
base_url,
|
|
int(parsed["account_id"]),
|
|
str(parsed["file_id"]),
|
|
token,
|
|
signature_enabled,
|
|
target_format,
|
|
)
|
|
if replaced != first:
|
|
lines[0] = replaced
|
|
file_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
updated += 1
|
|
except Exception:
|
|
continue
|
|
|
|
return {"total": total, "matched": matched, "updated": updated}
|
|
|
|
async def delete_task_output(self, task_name: str) -> bool:
|
|
task_root = self._get_task_root(task_name)
|
|
if not task_root.exists():
|
|
return False
|
|
shutil.rmtree(task_root, ignore_errors=False)
|
|
return True
|
|
|
|
async def pause_tasks_by_account(
|
|
self,
|
|
account_id: int,
|
|
reason: str,
|
|
message: Optional[str] = None,
|
|
) -> int:
|
|
"""账号禁用/认证失效时批量暂停该账号的 STRM 任务,只对 running 生效。"""
|
|
if reason not in ("account_disabled", "auth_failure"):
|
|
if self._logger:
|
|
self._logger.warning(
|
|
f"STRM pause_tasks_by_account 收到未知 reason: {reason}"
|
|
)
|
|
return 0
|
|
|
|
paused = 0
|
|
target_tasks = [
|
|
task for task in list(self._tasks.values())
|
|
if task.account_id == account_id and task.status == "running"
|
|
]
|
|
|
|
for task in target_tasks:
|
|
self._queued_tasks.discard(task.id)
|
|
self._manual_triggered_tasks.discard(task.id)
|
|
future = self._running_task_futures.get(task.id)
|
|
if future and not future.done():
|
|
future.cancel()
|
|
|
|
for task in target_tasks:
|
|
try:
|
|
await self._auto_pause_task(task, reason=reason, message=message)
|
|
paused += 1
|
|
except Exception as e:
|
|
if self._logger:
|
|
self._logger.error(
|
|
f"按账号暂停 STRM 任务失败: task={task.id} err={e}"
|
|
)
|
|
|
|
if paused > 0 and self._logger:
|
|
self._logger.info(
|
|
f"账号 {account_id} 状态变化({reason}),已暂停 {paused} 个 STRM 同步任务"
|
|
)
|
|
return paused
|
|
|
|
async def resume_tasks_by_account(self, account_id: int) -> int:
|
|
"""账号恢复可用时只恢复 account_disabled/auth_failure 的任务,不触碰用户手动暂停的。"""
|
|
resumed = 0
|
|
scan_interval, _, _ = await self._get_runtime_settings()
|
|
try:
|
|
for task in list(self._tasks.values()):
|
|
if task.account_id != account_id:
|
|
continue
|
|
if task.status != "paused":
|
|
continue
|
|
if task.paused_reason not in ("account_disabled", "auth_failure"):
|
|
continue
|
|
|
|
task.status = "running"
|
|
task.paused_reason = None
|
|
task.error_message = None
|
|
# 同步清掉遗留的错误标记,避免前端错误图标残留
|
|
task.last_scan_status = None
|
|
task.next_run_time = datetime.now()
|
|
await db.update_strm_sync_task(
|
|
task.id,
|
|
status="running",
|
|
paused_reason=None,
|
|
last_scan_status=None,
|
|
error_message=None,
|
|
)
|
|
resumed += 1
|
|
|
|
if resumed > 0 and self._logger:
|
|
self._logger.info(
|
|
f"账号 {account_id} 恢复可用,已自动恢复 {resumed} 个 STRM 同步任务"
|
|
)
|
|
except Exception as e:
|
|
if self._logger:
|
|
self._logger.error(f"按账号恢复 STRM 任务失败: {e}")
|
|
return resumed
|
|
|
|
async def remove_tasks_by_account(self, account_id: int) -> int:
|
|
target_tasks = [task for task in self._tasks.values() if task.account_id == account_id]
|
|
removed = 0
|
|
|
|
for task in target_tasks:
|
|
future = self._running_task_futures.get(task.id)
|
|
if future and not future.done():
|
|
future.cancel()
|
|
|
|
running_futures = [
|
|
self._running_task_futures[task.id]
|
|
for task in target_tasks
|
|
if task.id in self._running_task_futures
|
|
]
|
|
if running_futures:
|
|
await asyncio.gather(*running_futures, return_exceptions=True)
|
|
|
|
for task in target_tasks:
|
|
self._running_tasks.discard(task.id)
|
|
self._queued_tasks.discard(task.id)
|
|
self._manual_triggered_tasks.discard(task.id)
|
|
self._running_account_ids.discard(task.account_id)
|
|
self._running_task_futures.pop(task.id, None)
|
|
self._tasks.pop(task.id, None)
|
|
try:
|
|
await self.delete_task_output(task.name)
|
|
except FileNotFoundError:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
removed += 1
|
|
|
|
if removed:
|
|
await db.delete_strm_sync_tasks_by_account(account_id)
|
|
return removed
|
|
|
|
|
|
strm_sync_manager = StrmSyncManager()
|