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