616 lines
24 KiB
Python
616 lines
24 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import re
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from . import emby_collections, emby_users, favorites as favorites_service
|
|
from . import music_covers as music_service
|
|
from .music_covers import ProcessOptions
|
|
|
|
STATE_FILE = Path(os.environ.get("TASKS_STATE_FILE", os.environ.get("EMBY_TASKS_STATE_FILE", "cache/emby-tasks-state.json")))
|
|
CACHE_DIR = Path("cache")
|
|
EMBY_IMAGE_CACHE_DIR = CACHE_DIR / "emby_images"
|
|
CLEAN_POSTER_CACHE_DIR = CACHE_DIR / "clean_posters"
|
|
IMPORT_CACHE_DIR = CACHE_DIR / "imports"
|
|
|
|
TASK_DEFS: dict[str, dict[str, Any]] = {
|
|
"orphan_custom_images": {
|
|
"section": "emby",
|
|
"title": "Delete orphaned custom images for missing Emby items",
|
|
"description": "Requires access to Emby's internal metadata store. API-only deployments cannot safely locate these files.",
|
|
"supports_run": False,
|
|
"supports_automation": False,
|
|
"requires": "emby_server_data",
|
|
"run_label": "Run cleanup",
|
|
},
|
|
"empty_collections": {
|
|
"section": "emby",
|
|
"title": "Remove empty collections with zero items",
|
|
"description": "Deletes BoxSet collections that no longer contain any media.",
|
|
"supports_run": True,
|
|
"supports_automation": True,
|
|
"run_label": "Run cleanup",
|
|
},
|
|
"stale_favorites": {
|
|
"section": "emby",
|
|
"title": "Delete stale Favorites collections for removed users",
|
|
"description": "Removes '{User} Favorites' collections whose owner no longer exists in Emby.",
|
|
"supports_run": True,
|
|
"supports_automation": True,
|
|
"run_label": "Run cleanup",
|
|
},
|
|
"watched_favorites_cleanup": {
|
|
"section": "emby",
|
|
"title": "Bulk remove watched items from auto-managed collections",
|
|
"description": "Runs watched-item cleanup across every detected Favorites collection.",
|
|
"supports_run": True,
|
|
"supports_automation": True,
|
|
"run_label": "Run cleanup",
|
|
},
|
|
"duplicate_collections": {
|
|
"section": "emby",
|
|
"title": "Delete duplicate collections with the same normalized name",
|
|
"description": "Keeps the best candidate in each duplicate-name group and removes the extras.",
|
|
"supports_run": True,
|
|
"supports_automation": True,
|
|
"run_label": "Run cleanup",
|
|
},
|
|
"broken_library_paths": {
|
|
"section": "emby",
|
|
"title": "Remove broken library references where paths are not mounted",
|
|
"description": "Checks Emby library paths against the app host's filesystem and removes paths that no longer exist.",
|
|
"supports_run": True,
|
|
"supports_automation": True,
|
|
"run_label": "Run cleanup",
|
|
},
|
|
"generated_artwork_cache": {
|
|
"section": "emby",
|
|
"title": "Clear old generated artwork cache entries",
|
|
"description": "Prunes stale generated preview and artwork cache files created by HomelabToolkit.",
|
|
"supports_run": True,
|
|
"supports_automation": True,
|
|
"retention_days": 30,
|
|
"run_label": "Run cleanup",
|
|
},
|
|
"unused_metadata_cache": {
|
|
"section": "emby",
|
|
"title": "Delete unused people/metadata images from app-managed cache",
|
|
"description": "Removes stale Emby source image, poster-cleanup, and import cache files older than the configured retention window.",
|
|
"supports_run": True,
|
|
"supports_automation": True,
|
|
"retention_days": 45,
|
|
"run_label": "Run cleanup",
|
|
},
|
|
"navidrome_recent_maintenance": {
|
|
"section": "navidrome",
|
|
"title": "Maintain recently changed albums",
|
|
"description": "Scans albums changed in the last 2 hours and normalizes folders, track names, and missing covers.",
|
|
"supports_run": True,
|
|
"supports_automation": True,
|
|
"requires": "music_root",
|
|
"run_label": "Run maintenance",
|
|
},
|
|
"navidrome_cover_backfill": {
|
|
"section": "navidrome",
|
|
"title": "Backfill missing album covers",
|
|
"description": "Downloads missing cover.jpg artwork across the music library.",
|
|
"supports_run": True,
|
|
"supports_automation": True,
|
|
"requires": "music_root",
|
|
"run_label": "Fetch covers",
|
|
},
|
|
"navidrome_lyrics_backfill": {
|
|
"section": "navidrome",
|
|
"title": "Backfill missing lyrics sidecars",
|
|
"description": "Downloads missing .lrc or .txt lyrics sidecars for tracks that do not already have them.",
|
|
"supports_run": True,
|
|
"supports_automation": True,
|
|
"requires": "music_root",
|
|
"run_label": "Fetch lyrics",
|
|
},
|
|
"navidrome_file_cleanup": {
|
|
"section": "navidrome",
|
|
"title": "Remove extra non-library files",
|
|
"description": "Deletes non-audio, non-cover, and non-lyrics files from album folders.",
|
|
"supports_run": True,
|
|
"supports_automation": True,
|
|
"requires": "music_root",
|
|
"run_label": "Run cleanup",
|
|
},
|
|
}
|
|
|
|
SECTION_TITLES = {
|
|
"emby": "Emby Cleanup",
|
|
"navidrome": "Navidrome Cleanup",
|
|
}
|
|
|
|
|
|
def _default_task_settings(task_id: str) -> dict[str, Any]:
|
|
task = TASK_DEFS[task_id]
|
|
return {
|
|
"automation_enabled": False,
|
|
"weekday": 0,
|
|
"time": "03:00",
|
|
"retention_days": int(task.get("retention_days", 30)),
|
|
}
|
|
|
|
|
|
def default_settings() -> dict[str, dict[str, Any]]:
|
|
return {task_id: _default_task_settings(task_id) for task_id in TASK_DEFS}
|
|
|
|
|
|
def normalize_settings(value: Any) -> dict[str, dict[str, Any]]:
|
|
incoming = value if isinstance(value, dict) else {}
|
|
merged = default_settings()
|
|
for task_id, defaults in merged.items():
|
|
raw = incoming.get(task_id)
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
defaults["automation_enabled"] = bool(raw.get("automation_enabled", defaults["automation_enabled"]))
|
|
try:
|
|
weekday = int(raw.get("weekday", defaults["weekday"]))
|
|
except (TypeError, ValueError):
|
|
weekday = defaults["weekday"]
|
|
defaults["weekday"] = min(6, max(0, weekday))
|
|
time_value = str(raw.get("time", defaults["time"])).strip() or defaults["time"]
|
|
defaults["time"] = time_value
|
|
try:
|
|
retention = int(raw.get("retention_days", defaults["retention_days"]))
|
|
except (TypeError, ValueError):
|
|
retention = defaults["retention_days"]
|
|
defaults["retention_days"] = min(3650, max(1, retention))
|
|
return merged
|
|
|
|
|
|
def load_state() -> dict:
|
|
if not STATE_FILE.exists():
|
|
return {}
|
|
try:
|
|
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return {}
|
|
|
|
|
|
def save_state(state: dict) -> None:
|
|
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
STATE_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8")
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now().astimezone()
|
|
|
|
|
|
def _week_key(current: datetime | None = None) -> str:
|
|
iso = (current or _now()).isocalendar()
|
|
return f"{iso.year}-W{iso.week:02d}"
|
|
|
|
|
|
def parse_time(value: str) -> tuple[int, int]:
|
|
hh, mm = str(value).split(":", 1)
|
|
hour = int(hh)
|
|
minute = int(mm)
|
|
if not (0 <= hour < 24 and 0 <= minute < 60):
|
|
raise ValueError("time must be HH:MM")
|
|
return hour, minute
|
|
|
|
|
|
def next_run_at(config: dict[str, Any], base: datetime | None = None) -> datetime:
|
|
current = base or _now()
|
|
hour, minute = parse_time(config["time"])
|
|
days_ahead = (int(config["weekday"]) - current.weekday()) % 7
|
|
target = current.replace(hour=hour, minute=minute, second=0, microsecond=0) + timedelta(days=days_ahead)
|
|
if days_ahead == 0 and target <= current:
|
|
target += timedelta(days=7)
|
|
return target
|
|
|
|
|
|
def is_due(task_id: str, config: dict[str, Any], state: dict) -> bool:
|
|
if not config.get("automation_enabled"):
|
|
return False
|
|
current = _now()
|
|
hour, minute = parse_time(config["time"])
|
|
if current.weekday() != int(config["weekday"]):
|
|
return False
|
|
if current < current.replace(hour=hour, minute=minute, second=0, microsecond=0):
|
|
return False
|
|
last_week = ((state.get("tasks") or {}).get(task_id) or {}).get("last_automation_week")
|
|
return last_week != _week_key(current)
|
|
|
|
|
|
def record_run(task_id: str, result: dict, *, automated: bool) -> dict:
|
|
state = load_state()
|
|
tasks = state.setdefault("tasks", {})
|
|
entry = tasks.setdefault(task_id, {})
|
|
entry["last_run_at"] = _now().isoformat(timespec="seconds")
|
|
entry["last_result"] = result
|
|
entry["last_status"] = "ok" if result.get("ok", True) else "error"
|
|
if automated:
|
|
entry["last_automation_week"] = _week_key()
|
|
entry["last_automated_at"] = entry["last_run_at"]
|
|
save_state(state)
|
|
return entry
|
|
|
|
|
|
def _music_root_available() -> bool:
|
|
try:
|
|
return music_service.MUSIC_ROOT.exists()
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _supports_run(meta: dict[str, Any]) -> bool:
|
|
requirement = meta.get("requires")
|
|
if requirement == "emby_server_data":
|
|
return False
|
|
if requirement == "music_root":
|
|
return bool(meta.get("supports_run")) and _music_root_available()
|
|
return bool(meta.get("supports_run"))
|
|
|
|
|
|
def describe_tasks(settings: dict[str, dict[str, Any]]) -> list[dict]:
|
|
state = load_state().get("tasks", {})
|
|
items = []
|
|
for task_id, meta in TASK_DEFS.items():
|
|
config = settings[task_id]
|
|
try:
|
|
next_run = next_run_at(config).isoformat(timespec="seconds") if meta["supports_automation"] else None
|
|
schedule_error = None
|
|
except Exception as exc:
|
|
next_run = None
|
|
schedule_error = str(exc)
|
|
items.append(
|
|
{
|
|
"id": task_id,
|
|
"section": meta["section"],
|
|
"section_title": SECTION_TITLES.get(meta["section"], meta["section"].title()),
|
|
"title": meta["title"],
|
|
"description": meta["description"],
|
|
"supports_run": _supports_run(meta),
|
|
"supports_automation": meta["supports_automation"],
|
|
"requires": meta.get("requires"),
|
|
"run_label": meta.get("run_label", "Run task"),
|
|
"settings": config,
|
|
"status": state.get(task_id, {}),
|
|
"next_run_at": next_run,
|
|
"schedule_error": schedule_error,
|
|
}
|
|
)
|
|
return items
|
|
|
|
|
|
def _normalize_name(value: str) -> str:
|
|
return re.sub(r"[^a-z0-9]+", "", (value or "").casefold())
|
|
|
|
|
|
def _age_cutoff_days(retention_days: int) -> float:
|
|
return (_now() - timedelta(days=retention_days)).timestamp()
|
|
|
|
|
|
def _remove_paths(paths: list[Path], *, dry_run: bool) -> tuple[list[dict], int]:
|
|
removed: list[dict] = []
|
|
freed = 0
|
|
for path in paths:
|
|
try:
|
|
stat = path.stat()
|
|
except OSError:
|
|
continue
|
|
removed.append({"path": str(path), "size_bytes": stat.st_size, "modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds")})
|
|
freed += stat.st_size
|
|
if not dry_run:
|
|
try:
|
|
path.unlink()
|
|
except OSError:
|
|
pass
|
|
return removed, freed
|
|
|
|
|
|
async def _empty_collections(client, *, dry_run: bool, config: dict) -> dict:
|
|
collections = await emby_collections.find_all_collections(client)
|
|
zero_count_candidates = [c for c in collections if int(c.get("item_count") or 0) == 0]
|
|
targets = []
|
|
skipped = []
|
|
for collection in zero_count_candidates:
|
|
verified_count = await emby_collections.get_collection_item_count(client, collection["collection_id"])
|
|
if verified_count == 0:
|
|
targets.append({**collection, "verified_item_count": verified_count})
|
|
else:
|
|
skipped.append({**collection, "verified_item_count": verified_count})
|
|
if not dry_run:
|
|
for collection in targets:
|
|
await client.delete(f"/Items/{collection['collection_id']}")
|
|
return {
|
|
"ok": True,
|
|
"dry_run": dry_run,
|
|
"candidate_count": len(zero_count_candidates),
|
|
"verified_empty_count": len(targets),
|
|
"skipped_after_verification": len(skipped),
|
|
"matched_count": len(targets),
|
|
"removed_count": 0 if dry_run else len(targets),
|
|
"items": targets[:50],
|
|
"skipped_items": skipped[:50],
|
|
"message": (
|
|
f"{len(targets)} verified empty collection(s) "
|
|
f"{'would be removed' if dry_run else 'removed'}"
|
|
f"{f'; {len(skipped)} skipped after verification' if skipped else ''}."
|
|
),
|
|
}
|
|
|
|
|
|
async def _stale_favorites(client, *, dry_run: bool, config: dict) -> dict:
|
|
users = {u["name"].casefold() for u in await emby_users.fetch_users(client)}
|
|
targets = [c for c in await emby_collections.find_favorites_collections(client) if c["owner_name"] and c["owner_name"].casefold() not in users]
|
|
if not dry_run:
|
|
for collection in targets:
|
|
await client.delete(f"/Items/{collection['collection_id']}")
|
|
return {
|
|
"ok": True,
|
|
"dry_run": dry_run,
|
|
"matched_count": len(targets),
|
|
"removed_count": 0 if dry_run else len(targets),
|
|
"items": targets[:50],
|
|
"message": f"{len(targets)} stale Favorites collection(s) {'would be removed' if dry_run else 'removed'}.",
|
|
}
|
|
|
|
|
|
async def _watched_favorites_cleanup(client, *, dry_run: bool, config: dict) -> dict:
|
|
collections = await emby_collections.find_favorites_collections(client)
|
|
users = {u["name"].casefold(): u for u in await emby_users.fetch_users(client)}
|
|
summaries = []
|
|
watched_found = 0
|
|
removed_count = 0
|
|
for collection in collections:
|
|
owner = users.get((collection["owner_name"] or "").casefold())
|
|
if not owner:
|
|
continue
|
|
result = await favorites_service.cleanup_watched(client, collection["collection_id"], owner["id"], dry_run=dry_run)
|
|
if result["watched_found"]:
|
|
summaries.append({
|
|
"collection_id": collection["collection_id"],
|
|
"collection_name": collection["collection_name"],
|
|
"user_name": owner["name"],
|
|
"watched_found": result["watched_found"],
|
|
"removed_count": result["summary"]["removed_count"],
|
|
})
|
|
watched_found += result["watched_found"]
|
|
removed_count += result["summary"]["removed_count"]
|
|
return {
|
|
"ok": True,
|
|
"dry_run": dry_run,
|
|
"matched_count": len(summaries),
|
|
"watched_found": watched_found,
|
|
"removed_count": removed_count,
|
|
"items": summaries[:50],
|
|
"message": f"{watched_found} watched item(s) {'would be removed' if dry_run else 'removed'} across {len(summaries)} collection(s).",
|
|
}
|
|
|
|
|
|
async def _duplicate_collections(client, *, dry_run: bool, config: dict) -> dict:
|
|
collections = await emby_collections.find_all_collections(client)
|
|
groups: dict[str, list[dict]] = {}
|
|
for collection in collections:
|
|
groups.setdefault(_normalize_name(collection["collection_name"]), []).append(collection)
|
|
duplicates = []
|
|
for members in groups.values():
|
|
if len(members) < 2 or not members[0]["collection_name"]:
|
|
continue
|
|
ordered = sorted(members, key=lambda item: (-int(item.get("item_count") or 0), len(item["collection_name"]), item["collection_name"].casefold(), item["collection_id"]))
|
|
keep = ordered[0]
|
|
for dupe in ordered[1:]:
|
|
duplicates.append({
|
|
"keep_id": keep["collection_id"],
|
|
"keep_name": keep["collection_name"],
|
|
"remove_id": dupe["collection_id"],
|
|
"remove_name": dupe["collection_name"],
|
|
"remove_item_count": dupe.get("item_count"),
|
|
})
|
|
if not dry_run:
|
|
for dupe in duplicates:
|
|
await client.delete(f"/Items/{dupe['remove_id']}")
|
|
return {
|
|
"ok": True,
|
|
"dry_run": dry_run,
|
|
"matched_count": len(duplicates),
|
|
"removed_count": 0 if dry_run else len(duplicates),
|
|
"items": duplicates[:50],
|
|
"message": f"{len(duplicates)} duplicate collection(s) {'would be removed' if dry_run else 'removed'}.",
|
|
}
|
|
|
|
|
|
async def _broken_library_paths(client, *, dry_run: bool, config: dict) -> dict:
|
|
data = await client.get("/Library/SelectableMediaFolders")
|
|
folders = data if isinstance(data, list) else data.get("Items", []) if isinstance(data, dict) else []
|
|
broken = []
|
|
for folder in folders:
|
|
folder_id = folder.get("Id") or folder.get("Guid") or ""
|
|
folder_name = folder.get("Name") or "Library"
|
|
for sub in folder.get("SubFolders") or []:
|
|
path = str(sub.get("Path") or "").strip()
|
|
if not path:
|
|
continue
|
|
if not Path(path).exists():
|
|
broken.append({"library_id": folder_id, "library_name": folder_name, "path": path})
|
|
if not dry_run:
|
|
for entry in broken:
|
|
await client.post(
|
|
"/Library/VirtualFolders/Paths/Delete",
|
|
json={"Id": entry["library_id"], "Path": entry["path"], "RefreshLibrary": True},
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"dry_run": dry_run,
|
|
"matched_count": len(broken),
|
|
"removed_count": 0 if dry_run else len(broken),
|
|
"items": broken[:50],
|
|
"message": f"{len(broken)} broken library path(s) {'would be removed' if dry_run else 'removed'}.",
|
|
}
|
|
|
|
|
|
async def _generated_artwork_cache(client, *, dry_run: bool, config: dict) -> dict:
|
|
cutoff = _age_cutoff_days(int(config["retention_days"]))
|
|
targets = []
|
|
if CACHE_DIR.exists():
|
|
for path in CACHE_DIR.glob("*.png"):
|
|
try:
|
|
if path.stat().st_mtime < cutoff:
|
|
targets.append(path)
|
|
except OSError:
|
|
continue
|
|
removed, freed = _remove_paths(targets, dry_run=dry_run)
|
|
return {
|
|
"ok": True,
|
|
"dry_run": dry_run,
|
|
"matched_count": len(removed),
|
|
"removed_count": 0 if dry_run else len(removed),
|
|
"freed_bytes": 0 if dry_run else freed,
|
|
"items": removed[:50],
|
|
"message": f"{len(removed)} generated artwork cache file(s) {'would be removed' if dry_run else 'removed'}.",
|
|
}
|
|
|
|
|
|
async def _unused_metadata_cache(client, *, dry_run: bool, config: dict) -> dict:
|
|
cutoff = _age_cutoff_days(int(config["retention_days"]))
|
|
targets = []
|
|
for root in (EMBY_IMAGE_CACHE_DIR, CLEAN_POSTER_CACHE_DIR, IMPORT_CACHE_DIR):
|
|
if not root.exists():
|
|
continue
|
|
for path in root.rglob("*"):
|
|
if not path.is_file():
|
|
continue
|
|
try:
|
|
if path.stat().st_mtime < cutoff:
|
|
targets.append(path)
|
|
except OSError:
|
|
continue
|
|
removed, freed = _remove_paths(targets, dry_run=dry_run)
|
|
return {
|
|
"ok": True,
|
|
"dry_run": dry_run,
|
|
"matched_count": len(removed),
|
|
"removed_count": 0 if dry_run else len(removed),
|
|
"freed_bytes": 0 if dry_run else freed,
|
|
"items": removed[:50],
|
|
"message": f"{len(removed)} metadata/source cache file(s) {'would be removed' if dry_run else 'removed'}.",
|
|
}
|
|
|
|
|
|
def _summarize_music_actions(result: dict, *, dry_run: bool, interesting_actions: set[str], message_builder) -> dict:
|
|
changes = [
|
|
action for action in result.get("actions", [])
|
|
if action.get("action") in interesting_actions and action.get("level") in {"dry", "ok"}
|
|
]
|
|
change_count = len(changes)
|
|
sample = changes[:50]
|
|
return {
|
|
"ok": True,
|
|
"dry_run": dry_run,
|
|
"matched_count": change_count,
|
|
"removed_count": 0 if dry_run else change_count,
|
|
"counts": result.get("counts", {}),
|
|
"items": sample,
|
|
"message": message_builder(change_count, dry_run),
|
|
}
|
|
|
|
|
|
def _music_root_missing_result(*, dry_run: bool) -> dict:
|
|
return {
|
|
"ok": False,
|
|
"dry_run": dry_run,
|
|
"matched_count": 0,
|
|
"removed_count": 0,
|
|
"items": [],
|
|
"message": f"Music root is not mounted: {music_service.MUSIC_ROOT}",
|
|
}
|
|
|
|
|
|
async def _navidrome_recent_maintenance(client, *, dry_run: bool, config: dict) -> dict:
|
|
if not _music_root_available():
|
|
return _music_root_missing_result(dry_run=dry_run)
|
|
options = ProcessOptions(folder_cleanup=True, rename=True, covers=True, dry_run=dry_run, recent_only=True)
|
|
result = await asyncio.to_thread(music_service.process_library, options)
|
|
return _summarize_music_actions(
|
|
result,
|
|
dry_run=dry_run,
|
|
interesting_actions={"folder", "rename", "cover"},
|
|
message_builder=lambda count, is_dry: f"{count} recent library change(s) {'would be applied' if is_dry else 'applied'}.",
|
|
)
|
|
|
|
|
|
async def _navidrome_cover_backfill(client, *, dry_run: bool, config: dict) -> dict:
|
|
if not _music_root_available():
|
|
return _music_root_missing_result(dry_run=dry_run)
|
|
options = ProcessOptions(covers=True, dry_run=dry_run)
|
|
result = await asyncio.to_thread(music_service.process_library, options)
|
|
return _summarize_music_actions(
|
|
result,
|
|
dry_run=dry_run,
|
|
interesting_actions={"cover"},
|
|
message_builder=lambda count, is_dry: f"{count} missing cover(s) {'would be downloaded' if is_dry else 'downloaded'}.",
|
|
)
|
|
|
|
|
|
async def _navidrome_lyrics_backfill(client, *, dry_run: bool, config: dict) -> dict:
|
|
if not _music_root_available():
|
|
return _music_root_missing_result(dry_run=dry_run)
|
|
options = ProcessOptions(lyrics=True, dry_run=dry_run, covers=False)
|
|
result = await asyncio.to_thread(music_service.process_library, options)
|
|
return _summarize_music_actions(
|
|
result,
|
|
dry_run=dry_run,
|
|
interesting_actions={"lyrics"},
|
|
message_builder=lambda count, is_dry: f"{count} lyric sidecar(s) {'would be downloaded' if is_dry else 'downloaded'}.",
|
|
)
|
|
|
|
|
|
async def _navidrome_file_cleanup(client, *, dry_run: bool, config: dict) -> dict:
|
|
if not _music_root_available():
|
|
return _music_root_missing_result(dry_run=dry_run)
|
|
options = ProcessOptions(file_cleanup=True, dry_run=dry_run, covers=False)
|
|
result = await asyncio.to_thread(music_service.process_library, options)
|
|
return _summarize_music_actions(
|
|
result,
|
|
dry_run=dry_run,
|
|
interesting_actions={"remove"},
|
|
message_builder=lambda count, is_dry: f"{count} extra file(s) {'would be removed' if is_dry else 'removed'}.",
|
|
)
|
|
|
|
|
|
async def _unsupported(task_id: str, *, dry_run: bool) -> dict:
|
|
return {
|
|
"ok": False,
|
|
"dry_run": dry_run,
|
|
"matched_count": 0,
|
|
"removed_count": 0,
|
|
"items": [],
|
|
"message": f"{TASK_DEFS[task_id]['title']} requires Emby server data access and is not available from this deployment yet.",
|
|
}
|
|
|
|
|
|
RUNNERS = {
|
|
"orphan_custom_images": _unsupported,
|
|
"empty_collections": _empty_collections,
|
|
"stale_favorites": _stale_favorites,
|
|
"watched_favorites_cleanup": _watched_favorites_cleanup,
|
|
"duplicate_collections": _duplicate_collections,
|
|
"broken_library_paths": _broken_library_paths,
|
|
"generated_artwork_cache": _generated_artwork_cache,
|
|
"unused_metadata_cache": _unused_metadata_cache,
|
|
"navidrome_recent_maintenance": _navidrome_recent_maintenance,
|
|
"navidrome_cover_backfill": _navidrome_cover_backfill,
|
|
"navidrome_lyrics_backfill": _navidrome_lyrics_backfill,
|
|
"navidrome_file_cleanup": _navidrome_file_cleanup,
|
|
}
|
|
|
|
|
|
async def run_task(task_id: str, client, settings: dict[str, dict[str, Any]], *, dry_run: bool) -> dict:
|
|
if task_id not in TASK_DEFS:
|
|
raise KeyError(task_id)
|
|
runner = RUNNERS[task_id]
|
|
if runner is _unsupported:
|
|
return await runner(task_id, dry_run=dry_run)
|
|
return await runner(client, dry_run=dry_run, config=settings[task_id])
|