Homelabtoolkit v2
This commit is contained in:
@@ -0,0 +1,615 @@
|
||||
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])
|
||||
Reference in New Issue
Block a user