Files
2026-06-08 21:58:16 +12:00

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])