Homelabtoolkit v2
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""Audiobookshelf integration via its REST API.
|
||||
|
||||
Authentication uses a Bearer API token (Audiobookshelf account → API token).
|
||||
All functions take an ``httpx.AsyncClient`` so they share the app-wide client.
|
||||
This is the foundation for upcoming audiobook tooling (renaming, etc.); for now
|
||||
it covers connection status, libraries, and aggregate stats.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
ABS_URL = os.environ.get("AUDIOBOOKSHELF_URL", "")
|
||||
ABS_TOKEN = os.environ.get("AUDIOBOOKSHELF_TOKEN", "")
|
||||
|
||||
|
||||
class AudiobookshelfError(Exception):
|
||||
def __init__(self, message: str, status: int = 502):
|
||||
self.message = message
|
||||
self.status = status
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
return bool(ABS_URL and ABS_TOKEN)
|
||||
|
||||
|
||||
def _require_configured() -> None:
|
||||
if not is_configured():
|
||||
raise AudiobookshelfError(
|
||||
"Audiobookshelf is not configured. Set AUDIOBOOKSHELF_URL and AUDIOBOOKSHELF_TOKEN.",
|
||||
status=503,
|
||||
)
|
||||
|
||||
|
||||
def _base_url() -> str:
|
||||
return ABS_URL.rstrip("/")
|
||||
|
||||
|
||||
def _headers() -> dict:
|
||||
return {"Authorization": f"Bearer {ABS_TOKEN}", "Accept": "application/json"}
|
||||
|
||||
|
||||
async def _call(client: httpx.AsyncClient, path: str, params: dict | None = None) -> dict:
|
||||
_require_configured()
|
||||
url = f"{_base_url()}{path}"
|
||||
try:
|
||||
response = await client.get(url, params=params, headers=_headers())
|
||||
except httpx.RequestError as exc:
|
||||
raise AudiobookshelfError(f"Could not reach Audiobookshelf at {ABS_URL}: {exc}") from exc
|
||||
|
||||
if response.status_code in (401, 403):
|
||||
raise AudiobookshelfError("Audiobookshelf rejected the API token.", status=401)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise AudiobookshelfError(
|
||||
f"Audiobookshelf returned HTTP {response.status_code} for {path}.", status=502
|
||||
) from exc
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise AudiobookshelfError("Audiobookshelf returned an invalid response.", status=502) from exc
|
||||
|
||||
|
||||
async def ping(client: httpx.AsyncClient) -> dict:
|
||||
if not is_configured():
|
||||
return {"connected": False, "configured": False, "url": ABS_URL}
|
||||
try:
|
||||
me = await _call(client, "/api/me")
|
||||
return {
|
||||
"connected": True,
|
||||
"configured": True,
|
||||
"url": ABS_URL,
|
||||
"username": me.get("username"),
|
||||
}
|
||||
except AudiobookshelfError as exc:
|
||||
return {"connected": False, "configured": True, "url": ABS_URL, "error": exc.message}
|
||||
|
||||
|
||||
async def get_libraries(client: httpx.AsyncClient) -> list[dict]:
|
||||
payload = await _call(client, "/api/libraries")
|
||||
raw = payload.get("libraries") or []
|
||||
return [
|
||||
{
|
||||
"id": lib.get("id"),
|
||||
"name": lib.get("name") or "",
|
||||
"media_type": lib.get("mediaType") or "book",
|
||||
"provider": lib.get("provider"),
|
||||
}
|
||||
for lib in raw
|
||||
]
|
||||
|
||||
|
||||
async def _library_item_total(client: httpx.AsyncClient, library_id: str) -> int:
|
||||
try:
|
||||
payload = await _call(client, f"/api/libraries/{library_id}/items", {"limit": 0})
|
||||
return int(payload.get("total") or 0)
|
||||
except AudiobookshelfError:
|
||||
return 0
|
||||
|
||||
|
||||
async def get_stats(client: httpx.AsyncClient) -> dict:
|
||||
"""Aggregate stats across all libraries for the overview page."""
|
||||
libraries = await get_libraries(client)
|
||||
book_items = podcast_items = author_count = 0
|
||||
total_duration = total_size = num_tracks = 0
|
||||
library_views: list[dict] = []
|
||||
|
||||
for lib in libraries:
|
||||
stats: dict = {}
|
||||
try:
|
||||
stats = await _call(client, f"/api/libraries/{lib['id']}/stats")
|
||||
except AudiobookshelfError:
|
||||
stats = {}
|
||||
|
||||
items = int(stats.get("totalItems") or 0)
|
||||
if not items:
|
||||
items = await _library_item_total(client, lib["id"])
|
||||
|
||||
duration = int(stats.get("totalDuration") or 0)
|
||||
size = int(stats.get("totalSize") or 0)
|
||||
authors = int(stats.get("totalAuthors") or 0)
|
||||
tracks = int(stats.get("numAudioTracks") or 0)
|
||||
|
||||
if lib["media_type"] == "podcast":
|
||||
podcast_items += items
|
||||
else:
|
||||
book_items += items
|
||||
author_count += authors
|
||||
total_duration += duration
|
||||
total_size += size
|
||||
num_tracks += tracks
|
||||
|
||||
library_views.append(
|
||||
{
|
||||
"id": lib["id"],
|
||||
"name": lib["name"],
|
||||
"media_type": lib["media_type"],
|
||||
"items": items,
|
||||
"authors": authors,
|
||||
"duration": duration,
|
||||
"size": size,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"library_count": len(libraries),
|
||||
"book_count": book_items,
|
||||
"podcast_count": podcast_items,
|
||||
"author_count": author_count,
|
||||
"total_duration": total_duration,
|
||||
"total_size": total_size,
|
||||
"num_audio_tracks": num_tracks,
|
||||
"libraries": library_views,
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Bounded on-disk cache eviction so the container's disk footprint stays lean.
|
||||
|
||||
The app caches Emby source images, stamped posters, generated thumbnails and
|
||||
uploaded backgrounds under ``cache/``. Every entry is regenerated on demand, so
|
||||
none of it is precious — it just needs an upper bound. :func:`prune` enforces two
|
||||
limits, oldest-first:
|
||||
|
||||
1. **age** — anything older than ``max_age_days`` is removed;
|
||||
2. **size** — if the cache is still over ``max_total_mb``, the oldest files are
|
||||
removed until it fits.
|
||||
|
||||
All work is confined to the given cache directory and never raises (best-effort).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("homelabtoolkit.cache")
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.environ.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
# Tunable via env so the deployment can trade disk for cache warmth.
|
||||
MAX_AGE_DAYS = _int_env("CACHE_MAX_AGE_DAYS", 14)
|
||||
MAX_TOTAL_MB = _int_env("CACHE_MAX_MB", 512)
|
||||
SWEEP_INTERVAL_MIN = _int_env("CACHE_SWEEP_INTERVAL_MIN", 60)
|
||||
|
||||
|
||||
def prune(cache_dir: Path, *, max_age_days: int = MAX_AGE_DAYS, max_total_mb: int = MAX_TOTAL_MB) -> dict:
|
||||
"""Evict cache files by age, then by total size. Returns a small summary."""
|
||||
if not cache_dir.exists():
|
||||
return {"removed": 0, "freed_mb": 0.0, "kept_mb": 0.0}
|
||||
|
||||
now = time.time()
|
||||
entries: list[tuple[Path, float, int]] = []
|
||||
for path in cache_dir.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
entries.append((path, stat.st_mtime, stat.st_size))
|
||||
|
||||
removed = 0
|
||||
freed = 0
|
||||
|
||||
# 1) age pass
|
||||
age_cutoff = now - max_age_days * 86400
|
||||
survivors: list[tuple[Path, float, int]] = []
|
||||
for path, mtime, size in entries:
|
||||
if mtime < age_cutoff:
|
||||
if _unlink(path):
|
||||
removed += 1
|
||||
freed += size
|
||||
else:
|
||||
survivors.append((path, mtime, size))
|
||||
|
||||
# 2) size pass — drop oldest first until under budget
|
||||
budget = max_total_mb * 1024 * 1024
|
||||
total = sum(size for _, _, size in survivors)
|
||||
if total > budget:
|
||||
survivors.sort(key=lambda entry: entry[1]) # oldest mtime first
|
||||
for path, _mtime, size in survivors:
|
||||
if total <= budget:
|
||||
break
|
||||
if _unlink(path):
|
||||
total -= size
|
||||
removed += 1
|
||||
freed += size
|
||||
|
||||
return {
|
||||
"removed": removed,
|
||||
"freed_mb": round(freed / 1024 / 1024, 1),
|
||||
"kept_mb": round(total / 1024 / 1024, 1),
|
||||
}
|
||||
|
||||
|
||||
def _unlink(path: Path) -> bool:
|
||||
try:
|
||||
path.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
@@ -142,6 +142,13 @@ CREATE TABLE IF NOT EXISTS mb_cache (
|
||||
fetched_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS genre_overrides (
|
||||
artist_key TEXT PRIMARY KEY, -- normalized (lowercased) artist name
|
||||
artist TEXT NOT NULL, -- original display casing
|
||||
genre TEXT NOT NULL,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_albums_artist ON library_albums(artist_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_album ON library_tracks(album_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_path ON library_tracks(file_path);
|
||||
|
||||
@@ -129,6 +129,24 @@ async def list_collection_items(client, collection_id: str, user_id: str) -> lis
|
||||
return [normalize_item(raw) for raw in items]
|
||||
|
||||
|
||||
async def get_collection_item_count(client, collection_id: str) -> int:
|
||||
"""Ask Emby for the authoritative child count for one collection."""
|
||||
data = await client.get(
|
||||
"/Items",
|
||||
{
|
||||
"ParentId": collection_id,
|
||||
"Limit": "1",
|
||||
"Recursive": "false",
|
||||
},
|
||||
)
|
||||
if isinstance(data, dict):
|
||||
try:
|
||||
return int(data.get("TotalRecordCount", len(data.get("Items") or [])))
|
||||
except (TypeError, ValueError):
|
||||
return len(data.get("Items") or [])
|
||||
return len(data or [])
|
||||
|
||||
|
||||
async def add_collection_items(client, collection_id: str, item_ids: list[str]):
|
||||
"""Add items to a collection by id. No-op for an empty list."""
|
||||
if not item_ids:
|
||||
|
||||
@@ -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])
|
||||
@@ -0,0 +1,634 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import uuid
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
EMBY_USERS_CACHE_PATH = Path(os.environ.get("EMBY_USER_CACHE_PATH", "cache/homescreen-emby-users.json"))
|
||||
EMBY_USER_CONTEXT_CACHE_PATH = Path(os.environ.get("EMBY_USER_CONTEXT_CACHE_PATH", "cache/homescreen-emby-user-context.json"))
|
||||
HOMESCREEN_UPLOAD_DIR = Path(os.environ.get("HOMESCREEN_UPLOAD_DIR", "cache/homescreen_uploads"))
|
||||
HOMESCREEN_UPLOAD_STATE_PATH = Path(os.environ.get("HOMESCREEN_UPLOAD_STATE_PATH", "cache/homescreen-upload-state.json"))
|
||||
|
||||
|
||||
SECTION_TYPES = [
|
||||
{"value": "resume", "label": "Resume / Next Up"},
|
||||
{"value": "items", "label": "Items (filtered)"},
|
||||
{"value": "userviews", "label": "Libraries"},
|
||||
{"value": "boxset", "label": "Box Set"},
|
||||
{"value": "collections", "label": "Collections"},
|
||||
{"value": "latestepisodereleases", "label": "Latest episode releases"},
|
||||
{"value": "latestmoviereleases", "label": "Latest movie releases"},
|
||||
{"value": "latestmediablock", "label": "Latest media"},
|
||||
]
|
||||
|
||||
COLLECTION_TYPES = [
|
||||
{"value": "", "label": "(none)"},
|
||||
{"value": "movies", "label": "Movies"},
|
||||
{"value": "tvshows", "label": "TV Shows"},
|
||||
{"value": "boxsets", "label": "Box Sets"},
|
||||
]
|
||||
|
||||
ITEM_TYPES = ["Movie", "Series", "Episode", "BoxSet"]
|
||||
|
||||
SORT_OPTIONS = [
|
||||
{"value": "", "label": "(none)"},
|
||||
{"value": "default", "label": "Default (boxset)"},
|
||||
{"value": "DatePlayed", "label": "Date played"},
|
||||
{"value": "DateLastContentAdded,SortName", "label": "Date added"},
|
||||
{"value": "ProductionYear,PremiereDate,SortName", "label": "Release year"},
|
||||
{"value": "CommunityRating", "label": "Community rating"},
|
||||
{"value": "CriticRating,SortName", "label": "Critic rating"},
|
||||
{"value": "DateCreated,SortName", "label": "Date created"},
|
||||
{"value": "Random", "label": "Random"},
|
||||
{"value": "SortName", "label": "Name"},
|
||||
]
|
||||
|
||||
IMAGE_TYPES = [
|
||||
{"value": "", "label": "Default"},
|
||||
{"value": "Thumb", "label": "Thumb"},
|
||||
{"value": "Primary", "label": "Primary / Poster"},
|
||||
]
|
||||
|
||||
|
||||
def enums_payload() -> dict[str, Any]:
|
||||
return {
|
||||
"section_types": SECTION_TYPES,
|
||||
"collection_types": COLLECTION_TYPES,
|
||||
"item_types": ITEM_TYPES,
|
||||
"sort_options": SORT_OPTIONS,
|
||||
"image_types": IMAGE_TYPES,
|
||||
}
|
||||
|
||||
|
||||
def normalize_guid(value: str | None) -> str:
|
||||
return str(value or "").replace("-", "").strip().lower()
|
||||
|
||||
|
||||
def gen_id() -> str:
|
||||
return uuid.uuid4().hex[:32]
|
||||
|
||||
|
||||
def create_empty_section(user_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"UserId": user_id,
|
||||
"Name": "New Section",
|
||||
"CustomName": "New Section",
|
||||
"Id": gen_id(),
|
||||
"SectionType": "items",
|
||||
"ImageType": "Thumb",
|
||||
"CollectionType": "movies",
|
||||
"SortBy": "Random",
|
||||
"SortOrder": "Descending",
|
||||
"Monitor": [],
|
||||
"ItemTypes": ["Movie"],
|
||||
"ExcludedFolders": [],
|
||||
"CardSizeOffset": 0,
|
||||
"IncludeNextUpInResume": True,
|
||||
"Query": {
|
||||
"StudioIds": [],
|
||||
"TagIds": [],
|
||||
"GenreIds": [],
|
||||
"CollectionTypes": [],
|
||||
"IsPlayed": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def create_recently_watched_section(user_id: str, user_name: str = "") -> dict[str, Any]:
|
||||
label = f"Recently Watched - {user_name}" if user_name else "Recently Watched"
|
||||
return {
|
||||
"UserId": user_id,
|
||||
"Name": label,
|
||||
"CustomName": label,
|
||||
"Id": gen_id(),
|
||||
"SectionType": "items",
|
||||
"ImageType": "Thumb",
|
||||
"CollectionType": "",
|
||||
"SortBy": "DatePlayed",
|
||||
"SortOrder": "Descending",
|
||||
"Monitor": [],
|
||||
"ItemTypes": ["Movie", "Series"],
|
||||
"ExcludedFolders": [],
|
||||
"CardSizeOffset": 0,
|
||||
"IncludeNextUpInResume": True,
|
||||
"Query": {
|
||||
"StudioIds": [],
|
||||
"TagIds": [],
|
||||
"GenreIds": [],
|
||||
"CollectionTypes": [],
|
||||
"IsPlayed": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def create_boxset_section(user_id: str, collection_name: str = "", collection_id: str = "") -> dict[str, Any]:
|
||||
label = collection_name or "New Collection"
|
||||
return {
|
||||
"UserId": user_id,
|
||||
"Name": label,
|
||||
"CustomName": label,
|
||||
"Id": gen_id(),
|
||||
"SectionType": "boxset",
|
||||
"ImageType": "Thumb",
|
||||
"ItemTypes": [],
|
||||
"SortBy": "Random",
|
||||
"SortOrder": "Descending",
|
||||
"Monitor": [],
|
||||
"ExcludedFolders": [],
|
||||
"CardSizeOffset": 0,
|
||||
"IncludeNextUpInResume": True,
|
||||
"ParentItem": {
|
||||
"Name": label,
|
||||
"Id": str(collection_id or ""),
|
||||
},
|
||||
"ParentId": str(collection_id or ""),
|
||||
}
|
||||
|
||||
|
||||
def normalize_sections_for_user(sections: Any, expected_emby_guid: str) -> list[dict[str, Any]]:
|
||||
if not isinstance(sections, list):
|
||||
return []
|
||||
if not expected_emby_guid:
|
||||
return sections
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for section in sections:
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
next_section = dict(section)
|
||||
next_section["UserId"] = expected_emby_guid
|
||||
normalized.append(next_section)
|
||||
return normalized
|
||||
|
||||
|
||||
def _parse_json_blob(blob: Any) -> dict | None:
|
||||
if blob is None:
|
||||
return None
|
||||
try:
|
||||
text = blob if isinstance(blob, str) else bytes(blob).decode("utf-8")
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def blob_to_emby_guid(blob: bytes | bytearray | memoryview | None) -> str:
|
||||
if not blob:
|
||||
return ""
|
||||
raw = bytes(blob)
|
||||
if len(raw) != 16:
|
||||
return raw.hex().lower()
|
||||
reordered = bytes(
|
||||
[
|
||||
raw[3], raw[2], raw[1], raw[0],
|
||||
raw[5], raw[4],
|
||||
raw[7], raw[6],
|
||||
raw[8], raw[9], raw[10], raw[11], raw[12], raw[13], raw[14], raw[15],
|
||||
]
|
||||
)
|
||||
return reordered.hex().lower()
|
||||
|
||||
|
||||
def _has_table(conn: sqlite3.Connection, table_name: str) -> bool:
|
||||
row = conn.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", (table_name,)).fetchone()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def _users_table_columns(conn: sqlite3.Connection) -> list[str]:
|
||||
rows = conn.execute("PRAGMA table_info(Users)").fetchall()
|
||||
return [str(row[1]) for row in rows]
|
||||
|
||||
|
||||
def _find_column(columns: list[str], *patterns: str) -> str | None:
|
||||
lower_map = {col.lower(): col for col in columns}
|
||||
for pattern in patterns:
|
||||
for lower, original in lower_map.items():
|
||||
if lower == pattern.lower():
|
||||
return original
|
||||
return None
|
||||
|
||||
|
||||
def _load_users_table_users(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
columns = _users_table_columns(conn)
|
||||
name_col = _find_column(columns, "Username", "Name")
|
||||
guid_col = _find_column(columns, "Guid")
|
||||
id_col = _find_column(columns, "Id") or "Id"
|
||||
if not name_col:
|
||||
raise RuntimeError(f"Cannot find a name column in Users table. Columns found: {', '.join(columns)}")
|
||||
select_cols = ", ".join([col for col in [id_col, name_col, guid_col] if col])
|
||||
rows = conn.execute(f"SELECT {select_cols} FROM Users").fetchall()
|
||||
users: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
raw_id = row[id_col]
|
||||
raw_guid = row[guid_col] if guid_col else None
|
||||
emby_guid = ""
|
||||
guid = ""
|
||||
if raw_guid:
|
||||
if isinstance(raw_guid, (bytes, bytearray, memoryview)):
|
||||
buf = bytes(raw_guid)
|
||||
guid = buf.hex().upper()
|
||||
emby_guid = blob_to_emby_guid(buf)
|
||||
elif isinstance(raw_guid, str):
|
||||
clean = normalize_guid(raw_guid)
|
||||
emby_guid = clean
|
||||
guid = clean.upper()
|
||||
users.append(
|
||||
{
|
||||
"id": raw_id,
|
||||
"name": row[name_col] or f"User {raw_id}",
|
||||
"guid": guid,
|
||||
"embyGuid": emby_guid,
|
||||
"sourceTable": "Users",
|
||||
}
|
||||
)
|
||||
return users
|
||||
|
||||
|
||||
def _load_local_users(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
rows = conn.execute("SELECT Id, guid, data FROM LocalUsersv2").fetchall()
|
||||
users: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
parsed = _parse_json_blob(row["data"])
|
||||
guid_blob = row["guid"]
|
||||
guid = bytes(guid_blob).hex().upper() if guid_blob else ""
|
||||
emby_guid_from_blob = blob_to_emby_guid(guid_blob)
|
||||
emby_guid_from_json = normalize_guid((parsed or {}).get("IdString"))
|
||||
emby_guid = emby_guid_from_json or emby_guid_from_blob
|
||||
users.append(
|
||||
{
|
||||
"id": row["Id"],
|
||||
"name": (parsed or {}).get("Name") or f"User {row['Id']}",
|
||||
"guid": guid,
|
||||
"embyGuid": emby_guid,
|
||||
"sourceTable": "LocalUsersv2",
|
||||
"profile": parsed,
|
||||
}
|
||||
)
|
||||
return users
|
||||
|
||||
|
||||
def load_canonical_users(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
if _has_table(conn, "LocalUsersv2"):
|
||||
return _load_local_users(conn)
|
||||
if _has_table(conn, "Users"):
|
||||
return _load_users_table_users(conn)
|
||||
raise RuntimeError("No supported user table found. Expected LocalUsersv2 or Users.")
|
||||
|
||||
|
||||
def _home_screen_setting_rows(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return conn.execute(
|
||||
"""
|
||||
SELECT us.UserId, us.Value
|
||||
FROM UserSettings us
|
||||
JOIN UserSettingsKeys usk ON us.UserSettingsKeyId = usk.UserSettingsKeyId
|
||||
WHERE usk.Name = 'homescreensettings'
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
|
||||
def read_db(db_path: str) -> dict[str, Any]:
|
||||
path = Path(db_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Database file not found: {path}")
|
||||
conn = sqlite3.connect(str(path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
users = load_canonical_users(conn)
|
||||
settings_rows = _home_screen_setting_rows(conn)
|
||||
settings_map = {str(row["UserId"]): row["Value"] for row in settings_rows}
|
||||
user_ids = {str(user["id"]) for user in users}
|
||||
|
||||
matched_users = 0
|
||||
mismatched_users = 0
|
||||
normalized_users = 0
|
||||
missing_section_user_ids = 0
|
||||
hydrated_users: list[dict[str, Any]] = []
|
||||
|
||||
for user in users:
|
||||
raw_value = settings_map.get(str(user["id"]))
|
||||
sections: list[dict[str, Any]] = []
|
||||
try:
|
||||
if raw_value:
|
||||
sections = (json.loads(raw_value) or {}).get("Sections") or []
|
||||
except Exception:
|
||||
sections = []
|
||||
|
||||
actual_user_ids = sorted({normalize_guid(section.get("UserId")) for section in sections if normalize_guid(section.get("UserId"))})
|
||||
mismatched_section_user_ids = (
|
||||
[value for value in actual_user_ids if value != user["embyGuid"]]
|
||||
if user.get("embyGuid")
|
||||
else list(actual_user_ids)
|
||||
)
|
||||
missing_ids_for_user = sum(1 for section in sections if not normalize_guid(section.get("UserId")))
|
||||
normalized_sections = normalize_sections_for_user(sections, user.get("embyGuid", ""))
|
||||
sections_were_normalized = json.dumps(sections, sort_keys=True) != json.dumps(normalized_sections, sort_keys=True)
|
||||
|
||||
if mismatched_section_user_ids:
|
||||
mismatched_users += 1
|
||||
else:
|
||||
matched_users += 1
|
||||
if sections_were_normalized:
|
||||
normalized_users += 1
|
||||
missing_section_user_ids += missing_ids_for_user
|
||||
|
||||
profile = user.get("profile") or {}
|
||||
hydrated_users.append(
|
||||
{
|
||||
"id": user["id"],
|
||||
"name": user["name"],
|
||||
"dbName": user["name"],
|
||||
"guid": user.get("guid", ""),
|
||||
"embyGuid": user.get("embyGuid", ""),
|
||||
"embyName": None,
|
||||
"sections": normalized_sections,
|
||||
"details": {
|
||||
"sourceTable": user["sourceTable"],
|
||||
"lastLoginDate": profile.get("LastLoginDate"),
|
||||
"lastActivityDate": profile.get("LastActivityDate"),
|
||||
"usesIdForConfigurationPath": profile.get("UsesIdForConfigurationPath"),
|
||||
"importedCollectionsCount": len(profile.get("ImportedCollections") or []) if isinstance(profile.get("ImportedCollections"), list) else 0,
|
||||
},
|
||||
"match": {
|
||||
"sourceTable": user["sourceTable"],
|
||||
"settingsUserId": user["id"],
|
||||
"expectedSectionUserId": user.get("embyGuid", ""),
|
||||
"actualSectionUserIds": actual_user_ids,
|
||||
"mismatchedSectionUserIds": mismatched_section_user_ids,
|
||||
"missingSectionUserIds": missing_ids_for_user,
|
||||
"ok": not mismatched_section_user_ids,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
orphaned_settings_user_ids = sorted({str(row["UserId"]) for row in settings_rows if str(row["UserId"]) not in user_ids})
|
||||
return {
|
||||
"users": hydrated_users,
|
||||
"validation": {
|
||||
"userSource": users[0]["sourceTable"] if users else None,
|
||||
"userCount": len(hydrated_users),
|
||||
"settingsCount": len(settings_rows),
|
||||
"matchedUsers": matched_users,
|
||||
"mismatchedUsers": mismatched_users,
|
||||
"normalizedUsers": normalized_users,
|
||||
"missingSectionUserIds": missing_section_user_ids,
|
||||
"orphanedSettingsUserIds": orphaned_settings_user_ids,
|
||||
},
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def write_db(db_path: str, changes: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
path = Path(db_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Database file not found: {path}")
|
||||
conn = sqlite3.connect(str(path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
user_lookup = {str(user["id"]): user for user in load_canonical_users(conn)}
|
||||
key_row = conn.execute(
|
||||
"SELECT UserSettingsKeyId FROM UserSettingsKeys WHERE Name = 'homescreensettings'"
|
||||
).fetchone()
|
||||
if not key_row:
|
||||
raise RuntimeError("'homescreensettings' key not found in UserSettingsKeys table")
|
||||
key_id = key_row["UserSettingsKeyId"]
|
||||
count = 0
|
||||
normalized_sections = 0
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
for change in changes:
|
||||
user_id = str(change.get("userId"))
|
||||
sections = change.get("sections")
|
||||
user = user_lookup.get(user_id)
|
||||
if not user:
|
||||
raise RuntimeError(f"UserId {user_id} does not exist in {path}")
|
||||
next_sections = normalize_sections_for_user(sections, user.get("embyGuid", ""))
|
||||
if json.dumps(next_sections, sort_keys=True) != json.dumps(sections, sort_keys=True):
|
||||
normalized_sections += len(next_sections)
|
||||
value = json.dumps({"Sections": next_sections}, separators=(",", ":"))
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM UserSettings WHERE UserId = ? AND UserSettingsKeyId = ?",
|
||||
(change.get("userId"), key_id),
|
||||
).fetchone()
|
||||
if exists:
|
||||
conn.execute(
|
||||
"UPDATE UserSettings SET Value = ? WHERE UserId = ? AND UserSettingsKeyId = ?",
|
||||
(value, change.get("userId"), key_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO UserSettings (UserId, UserSettingsKeyId, Value) VALUES (?, ?, ?)",
|
||||
(change.get("userId"), key_id, value),
|
||||
)
|
||||
count += 1
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
return {"ok": True, "count": count, "normalizedSections": normalized_sections}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def generate_sql(users: list[dict[str, Any]], original_users: list[dict[str, Any]]) -> str:
|
||||
statements: list[str] = []
|
||||
original_lookup = {str(user.get("id")): user for user in original_users}
|
||||
for user in users:
|
||||
sections = user.get("sections")
|
||||
if not isinstance(sections, list):
|
||||
continue
|
||||
original = original_lookup.get(str(user.get("id")))
|
||||
if not original:
|
||||
continue
|
||||
orig_json = json.dumps({"Sections": original.get("sections") or []}, separators=(",", ":"))
|
||||
new_json = json.dumps({"Sections": sections}, separators=(",", ":"))
|
||||
if orig_json == new_json:
|
||||
continue
|
||||
escaped_value = new_json.replace("'", "''")
|
||||
statements.extend(
|
||||
[
|
||||
f"-- User: {user.get('name', 'Unknown')} (DB ID: {user.get('id')})",
|
||||
"UPDATE UserSettings "
|
||||
f"SET Value = '{escaped_value}' "
|
||||
f"WHERE UserId = {user.get('id')} "
|
||||
"AND UserSettingsKeyId = "
|
||||
"(SELECT UserSettingsKeyId FROM UserSettingsKeys WHERE Name = 'homescreensettings');",
|
||||
"",
|
||||
]
|
||||
)
|
||||
if not statements:
|
||||
return "-- No changes detected"
|
||||
header = [
|
||||
"-- ===========================================",
|
||||
"-- Emby Home Screen Settings Update",
|
||||
f"-- Generated: {datetime.now().astimezone().isoformat(timespec='seconds')}",
|
||||
"-- ===========================================",
|
||||
"-- IMPORTANT: Stop Emby before running this!",
|
||||
"-- sqlite3 /path/to/users.db < this_file.sql",
|
||||
"-- Then restart Emby.",
|
||||
"-- ===========================================",
|
||||
"",
|
||||
"BEGIN TRANSACTION;",
|
||||
"",
|
||||
]
|
||||
return "\n".join(header + statements + ["COMMIT;"])
|
||||
|
||||
|
||||
def _read_json_cache(path: Path, default: Any) -> Any:
|
||||
if not path.exists():
|
||||
return default
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _write_json_cache(path: Path, payload: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _iso_now() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _upload_path(upload_id: str) -> Path:
|
||||
safe_id = "".join(ch for ch in str(upload_id or "") if ch.isalnum() or ch in ("-", "_")).strip()
|
||||
if not safe_id:
|
||||
raise ValueError("Invalid upload id.")
|
||||
return HOMESCREEN_UPLOAD_DIR / f"{safe_id}.db"
|
||||
|
||||
|
||||
def get_active_upload() -> dict[str, Any] | None:
|
||||
payload = _read_json_cache(HOMESCREEN_UPLOAD_STATE_PATH, {})
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
upload_id = str(payload.get("upload_id") or "").strip()
|
||||
if not upload_id:
|
||||
return None
|
||||
path = _upload_path(upload_id)
|
||||
if not path.exists():
|
||||
return None
|
||||
return {
|
||||
"upload_id": upload_id,
|
||||
"filename": str(payload.get("filename") or path.name),
|
||||
"size_bytes": int(payload.get("size_bytes") or path.stat().st_size),
|
||||
"uploaded_at": payload.get("uploaded_at"),
|
||||
"sha256": str(payload.get("sha256") or ""),
|
||||
"path": str(path),
|
||||
}
|
||||
|
||||
|
||||
def save_uploaded_db(filename: str, content: bytes) -> dict[str, Any]:
|
||||
if not content:
|
||||
raise ValueError("Uploaded file is empty.")
|
||||
HOMESCREEN_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
upload_id = uuid.uuid4().hex
|
||||
path = _upload_path(upload_id)
|
||||
path.write_bytes(content)
|
||||
meta = {
|
||||
"upload_id": upload_id,
|
||||
"filename": Path(filename or "users.db").name or "users.db",
|
||||
"size_bytes": len(content),
|
||||
"uploaded_at": _iso_now(),
|
||||
"sha256": hashlib.sha256(content).hexdigest(),
|
||||
}
|
||||
_write_json_cache(HOMESCREEN_UPLOAD_STATE_PATH, meta)
|
||||
return {**meta, "path": str(path)}
|
||||
|
||||
|
||||
def resolve_db_source(db_path: str | None = None, upload_id: str | None = None) -> tuple[str, dict[str, Any] | None]:
|
||||
if upload_id:
|
||||
path = _upload_path(upload_id)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Uploaded database not found for id {upload_id}.")
|
||||
active = get_active_upload()
|
||||
if active and active.get("upload_id") == upload_id:
|
||||
return str(path), active
|
||||
stat = path.stat()
|
||||
return str(path), {
|
||||
"upload_id": upload_id,
|
||||
"filename": path.name,
|
||||
"size_bytes": stat.st_size,
|
||||
"uploaded_at": None,
|
||||
"sha256": "",
|
||||
"path": str(path),
|
||||
}
|
||||
active = get_active_upload()
|
||||
if active:
|
||||
return active["path"], active
|
||||
if db_path:
|
||||
return db_path, None
|
||||
raise FileNotFoundError("No homescreen database source configured.")
|
||||
|
||||
|
||||
def read_cached_emby_users() -> dict[str, Any]:
|
||||
payload = _read_json_cache(EMBY_USERS_CACHE_PATH, {"users": [], "lastSyncedAt": None})
|
||||
users = payload.get("users") if isinstance(payload, dict) else []
|
||||
last_synced = payload.get("lastSyncedAt") if isinstance(payload, dict) else None
|
||||
normalized = [
|
||||
{"embyGuid": normalize_guid(user.get("embyGuid")), "name": str(user.get("name") or "").strip()}
|
||||
for user in users
|
||||
if normalize_guid(user.get("embyGuid")) and str(user.get("name") or "").strip()
|
||||
]
|
||||
normalized.sort(key=lambda user: (user["name"].lower(), user["embyGuid"]))
|
||||
return {"users": normalized, "lastSyncedAt": last_synced}
|
||||
|
||||
|
||||
def write_cached_emby_users(users: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
fetched_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
payload = {
|
||||
"users": [
|
||||
{"embyGuid": normalize_guid(user.get("embyGuid")), "name": str(user.get("name") or "").strip()}
|
||||
for user in users
|
||||
if normalize_guid(user.get("embyGuid")) and str(user.get("name") or "").strip()
|
||||
],
|
||||
"lastSyncedAt": fetched_at,
|
||||
}
|
||||
payload["users"].sort(key=lambda user: (user["name"].lower(), user["embyGuid"]))
|
||||
_write_json_cache(EMBY_USERS_CACHE_PATH, payload)
|
||||
return payload
|
||||
|
||||
|
||||
def apply_cached_emby_names(users: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
cached = read_cached_emby_users()
|
||||
lookup = {user["embyGuid"]: user for user in cached["users"]}
|
||||
enriched = []
|
||||
matched = 0
|
||||
for user in users:
|
||||
emby_guid = normalize_guid(user.get("embyGuid"))
|
||||
cached_user = lookup.get(emby_guid)
|
||||
if cached_user:
|
||||
matched += 1
|
||||
enriched.append(
|
||||
{
|
||||
**user,
|
||||
"dbName": user.get("dbName") or user.get("name"),
|
||||
"embyName": cached_user["name"] if cached_user else user.get("embyName"),
|
||||
"name": cached_user["name"] if cached_user else user.get("name"),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"users": enriched,
|
||||
"cache": {
|
||||
"matchedCount": matched,
|
||||
"totalCachedUsers": len(cached["users"]),
|
||||
"lastSyncedAt": cached.get("lastSyncedAt"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def read_cached_user_context(emby_guid: str) -> dict[str, Any] | None:
|
||||
payload = _read_json_cache(EMBY_USER_CONTEXT_CACHE_PATH, {})
|
||||
return payload.get(normalize_guid(emby_guid)) if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
def write_cached_user_context(emby_guid: str, context: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = _read_json_cache(EMBY_USER_CONTEXT_CACHE_PATH, {})
|
||||
normalized_guid = normalize_guid(emby_guid)
|
||||
payload[normalized_guid] = context
|
||||
_write_json_cache(EMBY_USER_CONTEXT_CACHE_PATH, payload)
|
||||
return context
|
||||
+108
-1
@@ -14,12 +14,15 @@ Both are synchronous (filesystem + blocking HTTP); call them from FastAPI via
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from typing import Callable, Iterator
|
||||
|
||||
import requests
|
||||
|
||||
@@ -27,6 +30,10 @@ try: # Optional: only needed for the "find missing year/cover" online lookups.
|
||||
import musicbrainzngs
|
||||
|
||||
musicbrainzngs.set_useragent("HomelabToolkit", "1.0", "homelab-toolkit@example.com")
|
||||
# musicbrainzngs logs "uncaught attribute"/"uncaught tag" at INFO whenever the
|
||||
# MusicBrainz XML carries fields it doesn't model (e.g. release-group type-id).
|
||||
# Harmless noise — keep only real warnings.
|
||||
logging.getLogger("musicbrainzngs").setLevel(logging.WARNING)
|
||||
_HAS_MUSICBRAINZ = True
|
||||
except Exception: # pragma: no cover - optional dependency
|
||||
_HAS_MUSICBRAINZ = False
|
||||
@@ -46,6 +53,9 @@ YEAR_ALBUM_FOLDER_RE = re.compile(r"^\s*(\d{4})\s*-\s*(.+?)\s*$")
|
||||
LogCallback = Callable[[dict], None]
|
||||
|
||||
|
||||
DEFAULT_RECENT_WINDOW_SECONDS = 2 * 60 * 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessOptions:
|
||||
folder_cleanup: bool = False
|
||||
@@ -54,9 +64,15 @@ class ProcessOptions:
|
||||
lyrics: bool = False
|
||||
covers: bool = True
|
||||
dry_run: bool = True
|
||||
recent_only: bool = False
|
||||
recent_window_seconds: int = DEFAULT_RECENT_WINDOW_SECONDS
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ProcessOptions":
|
||||
try:
|
||||
window = int(data.get("recent_window_seconds") or DEFAULT_RECENT_WINDOW_SECONDS)
|
||||
except (TypeError, ValueError):
|
||||
window = DEFAULT_RECENT_WINDOW_SECONDS
|
||||
return cls(
|
||||
folder_cleanup=bool(data.get("folder_cleanup", False)),
|
||||
rename=bool(data.get("rename", False)),
|
||||
@@ -64,6 +80,8 @@ class ProcessOptions:
|
||||
lyrics=bool(data.get("lyrics", False)),
|
||||
covers=bool(data.get("covers", True)),
|
||||
dry_run=bool(data.get("dry_run", True)),
|
||||
recent_only=bool(data.get("recent_only", False)),
|
||||
recent_window_seconds=window,
|
||||
)
|
||||
|
||||
|
||||
@@ -246,6 +264,16 @@ def analyze_album(album_folder: Path) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _was_created_recently(folder: Path, window_seconds: int) -> bool:
|
||||
"""True if the folder was created/modified within the window (recent-only mode)."""
|
||||
try:
|
||||
stat = folder.stat()
|
||||
except OSError:
|
||||
return False
|
||||
age = time.time() - max(stat.st_ctime, stat.st_mtime)
|
||||
return 0 <= age <= window_seconds
|
||||
|
||||
|
||||
def _iter_album_folders(root: Path):
|
||||
for first_level in root.iterdir():
|
||||
if not first_level.is_dir():
|
||||
@@ -513,6 +541,16 @@ def process_library(
|
||||
else:
|
||||
folders = list(_iter_album_folders(root))
|
||||
|
||||
if options.recent_only and not album_paths:
|
||||
before = len(folders)
|
||||
folders = [f for f in folders if _was_created_recently(f, options.recent_window_seconds)]
|
||||
rec.emit(
|
||||
"info",
|
||||
"recent",
|
||||
f"Recent-only: {len(folders)} of {before} albums modified in the last "
|
||||
f"{options.recent_window_seconds // 3600}h",
|
||||
)
|
||||
|
||||
rec.emit(
|
||||
"info",
|
||||
"start",
|
||||
@@ -531,3 +569,72 @@ def process_library(
|
||||
"actions": rec.actions,
|
||||
"counts": rec.counts,
|
||||
}
|
||||
|
||||
|
||||
# ── streaming variants (disk-efficient; yield as work happens) ────────────────
|
||||
|
||||
|
||||
def scan_library_stream(root: Path | None = None) -> Iterator[dict]:
|
||||
"""Yield one album analysis at a time so the UI can render incrementally.
|
||||
|
||||
Memory stays flat (no full list is accumulated) and the slow NAS walk
|
||||
streams results to the caller as each album folder is inspected.
|
||||
"""
|
||||
root = root or MUSIC_ROOT
|
||||
if not root.exists():
|
||||
yield {"type": "error", "message": f"Music root not found: {root}"}
|
||||
return
|
||||
|
||||
yield {"type": "start", "root": str(root)}
|
||||
album_count = missing_cover = needs_rename = extra_files = 0
|
||||
for album_folder in _iter_album_folders(root):
|
||||
try:
|
||||
album = analyze_album(album_folder)
|
||||
except OSError:
|
||||
continue
|
||||
album_count += 1
|
||||
if not album["has_cover"]:
|
||||
missing_cover += 1
|
||||
if album["needs_folder_rename"]:
|
||||
needs_rename += 1
|
||||
extra_files += album["extra_file_count"]
|
||||
yield {"type": "album", "album": album, "scanned": album_count}
|
||||
|
||||
yield {
|
||||
"type": "summary",
|
||||
"root": str(root),
|
||||
"album_count": album_count,
|
||||
"missing_cover_count": missing_cover,
|
||||
"needs_rename_count": needs_rename,
|
||||
"extra_file_count": extra_files,
|
||||
}
|
||||
|
||||
|
||||
def process_library_stream(
|
||||
options: ProcessOptions,
|
||||
*,
|
||||
root: Path | None = None,
|
||||
album_paths: list[str] | None = None,
|
||||
) -> Iterator[dict]:
|
||||
"""Run maintenance and yield each action the moment it happens.
|
||||
|
||||
``process_library`` already reports through a ``log`` callback; we bridge that
|
||||
to a queue drained by this generator so the HTTP response streams live.
|
||||
"""
|
||||
events: queue.Queue = queue.Queue()
|
||||
sentinel = object()
|
||||
|
||||
def worker():
|
||||
try:
|
||||
process_library(options, root=root, log=events.put, album_paths=album_paths)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
events.put({"level": "warn", "action": "error", "message": str(exc)})
|
||||
finally:
|
||||
events.put(sentinel)
|
||||
|
||||
threading.Thread(target=worker, name="music_process_stream", daemon=True).start()
|
||||
while True:
|
||||
item = events.get()
|
||||
if item is sentinel:
|
||||
break
|
||||
yield item
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
"""Music tag maintenance — the metadata sibling to :mod:`music_covers`.
|
||||
|
||||
Where ``music_covers`` renames folders/files and fetches sidecar art, this module
|
||||
rewrites the *tags inside* audio files. Three jobs, all honouring ``dry_run``:
|
||||
|
||||
* **Genres** — look the album up on MusicBrainz and write a single canonical
|
||||
genre to every track, so the library's genre facet stays consistent.
|
||||
* **Junk** — strip comment / encoder / URL / embedded-lyrics frames that rippers
|
||||
and stores leave behind.
|
||||
* **Track numbers** — normalize the ``tracknumber`` / ``discnumber`` tag value
|
||||
(drop the ``/total`` suffix, zero-pad to two digits).
|
||||
|
||||
Format-aware: MP3 (ID3), FLAC/Ogg (Vorbis comments) and M4A (MP4 atoms) each get
|
||||
their own handlers, dispatched on the loaded tag object. Like ``music_covers``,
|
||||
everything is synchronous (filesystem + blocking HTTP) — call from FastAPI via
|
||||
``asyncio.to_thread`` or the streaming generator below.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
from mutagen import File as MutagenFile, MutagenError
|
||||
from mutagen.id3 import ID3
|
||||
from mutagen.mp4 import MP4Tags
|
||||
|
||||
from . import db
|
||||
from .music_covers import (
|
||||
AUDIO_EXTENSIONS,
|
||||
MUSIC_ROOT,
|
||||
DEFAULT_RECENT_WINDOW_SECONDS,
|
||||
LogCallback,
|
||||
_Recorder,
|
||||
_iter_album_folders,
|
||||
_was_created_recently,
|
||||
clean_track_number,
|
||||
get_album_metadata_from_files,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("homelabtoolkit.music_metadata")
|
||||
|
||||
try: # Optional: only needed for the online genre lookups.
|
||||
import musicbrainzngs
|
||||
|
||||
_HAS_MUSICBRAINZ = True
|
||||
except Exception: # pragma: no cover - optional dependency
|
||||
_HAS_MUSICBRAINZ = False
|
||||
|
||||
|
||||
# ── options ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetadataOptions:
|
||||
genres: bool = False
|
||||
strip_junk: bool = False
|
||||
normalize_tracks: bool = False
|
||||
dry_run: bool = True
|
||||
recent_only: bool = False
|
||||
recent_window_seconds: int = DEFAULT_RECENT_WINDOW_SECONDS
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "MetadataOptions":
|
||||
try:
|
||||
window = int(data.get("recent_window_seconds") or DEFAULT_RECENT_WINDOW_SECONDS)
|
||||
except (TypeError, ValueError):
|
||||
window = DEFAULT_RECENT_WINDOW_SECONDS
|
||||
return cls(
|
||||
genres=bool(data.get("genres", False)),
|
||||
strip_junk=bool(data.get("strip_junk", False)),
|
||||
normalize_tracks=bool(data.get("normalize_tracks", False)),
|
||||
dry_run=bool(data.get("dry_run", True)),
|
||||
recent_only=bool(data.get("recent_only", False)),
|
||||
recent_window_seconds=window,
|
||||
)
|
||||
|
||||
@property
|
||||
def any_mode(self) -> bool:
|
||||
return self.genres or self.strip_junk or self.normalize_tracks
|
||||
|
||||
|
||||
# ── genre canonicalization ────────────────────────────────────────────────────
|
||||
|
||||
# MusicBrainz genres are lowercase; map the messy/spaced forms to a clean label.
|
||||
_GENRE_CANON = {
|
||||
"hip hop": "Hip-Hop",
|
||||
"hip-hop": "Hip-Hop",
|
||||
"rnb": "R&B",
|
||||
"r and b": "R&B",
|
||||
"rhythm and blues": "R&B",
|
||||
"drum and bass": "Drum & Bass",
|
||||
"dnb": "Drum & Bass",
|
||||
"edm": "EDM",
|
||||
"idm": "IDM",
|
||||
"uk garage": "UK Garage",
|
||||
}
|
||||
_ACRONYMS = {"edm", "idm", "uk", "us", "dj"}
|
||||
|
||||
|
||||
def canonical_genre(name: str | None) -> str | None:
|
||||
if not name:
|
||||
return None
|
||||
key = name.strip().lower()
|
||||
if not key:
|
||||
return None
|
||||
if key in _GENRE_CANON:
|
||||
return _GENRE_CANON[key]
|
||||
words = [w.upper() if w in _ACRONYMS else w.capitalize() for w in re.split(r"\s+", key)]
|
||||
return " ".join(words)
|
||||
|
||||
|
||||
def find_album_genre(artist: str, album: str) -> str | None:
|
||||
"""Return a single canonical genre for an album via MusicBrainz, or None.
|
||||
|
||||
Prefers the release-group's curated genres (highest vote count); falls back
|
||||
to its folksonomy tags, then to the artist's genres.
|
||||
"""
|
||||
if not _HAS_MUSICBRAINZ or not (artist and album):
|
||||
return None
|
||||
try:
|
||||
result = musicbrainzngs.search_release_groups(artist=artist, releasegroup=album, limit=3)
|
||||
groups = result.get("release-group-list", [])
|
||||
if not groups:
|
||||
return None
|
||||
rgid = groups[0]["id"]
|
||||
genre = _genre_from_release_group(rgid)
|
||||
if genre:
|
||||
return genre
|
||||
artist_credit = groups[0].get("artist-credit") or []
|
||||
for credit in artist_credit:
|
||||
mbid = (credit.get("artist") or {}).get("id") if isinstance(credit, dict) else None
|
||||
if mbid:
|
||||
genre = _genre_from_artist(mbid)
|
||||
if genre:
|
||||
return genre
|
||||
except Exception as exc: # network / parse / lookup failure — never fatal
|
||||
logger.debug("Genre lookup failed for %s - %s: %s", artist, album, exc)
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def find_artist_genre(artist: str) -> str | None:
|
||||
"""Return a single canonical genre for an *artist* via MusicBrainz, or None.
|
||||
|
||||
Resolved once per artist so every album by that artist gets the same genre
|
||||
(consistency over per-album accuracy — e.g. all A Perfect Circle albums land
|
||||
on one genre rather than a mix of Alternative Rock / Alternative Metal).
|
||||
"""
|
||||
if not _HAS_MUSICBRAINZ or not artist:
|
||||
return None
|
||||
try:
|
||||
result = musicbrainzngs.search_artists(artist=artist, limit=3)
|
||||
matches = result.get("artist-list", [])
|
||||
if not matches:
|
||||
return None
|
||||
return _genre_from_artist(matches[0]["id"])
|
||||
except Exception as exc: # network / parse / lookup failure — never fatal
|
||||
logger.debug("Artist genre lookup failed for %s: %s", artist, exc)
|
||||
return None
|
||||
|
||||
|
||||
# ── manual genre overrides (user-editable, persisted) ─────────────────────────
|
||||
|
||||
|
||||
def _artist_key(artist: str) -> str:
|
||||
return (artist or "").strip().lower()
|
||||
|
||||
|
||||
def list_genre_overrides() -> list[dict]:
|
||||
with db.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT artist, genre, updated_at FROM genre_overrides ORDER BY artist COLLATE NOCASE"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def set_genre_override(artist: str, genre: str) -> dict:
|
||||
artist = (artist or "").strip()
|
||||
genre = (genre or "").strip()
|
||||
if not artist or not genre:
|
||||
raise ValueError("Both artist and genre are required.")
|
||||
with db.connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO genre_overrides(artist_key, artist, genre, updated_at) VALUES(?,?,?,?) "
|
||||
"ON CONFLICT(artist_key) DO UPDATE SET artist=excluded.artist, genre=excluded.genre, "
|
||||
"updated_at=excluded.updated_at",
|
||||
(_artist_key(artist), artist, genre, db.now_iso()),
|
||||
)
|
||||
return {"artist": artist, "genre": genre}
|
||||
|
||||
|
||||
def delete_genre_override(artist: str) -> None:
|
||||
with db.connect() as conn:
|
||||
conn.execute("DELETE FROM genre_overrides WHERE artist_key=?", (_artist_key(artist),))
|
||||
|
||||
|
||||
def _overrides_map() -> dict[str, str]:
|
||||
return {_artist_key(o["artist"]): o["genre"] for o in list_genre_overrides()}
|
||||
|
||||
|
||||
def _best_genre(entries: list[dict] | None) -> str | None:
|
||||
if not entries:
|
||||
return None
|
||||
best = max(entries, key=lambda g: int(g.get("count") or 0))
|
||||
return canonical_genre(best.get("name"))
|
||||
|
||||
|
||||
def _genre_from_release_group(rgid: str) -> str | None:
|
||||
try:
|
||||
detail = musicbrainzngs.get_release_group_by_id(rgid, includes=["genres"])
|
||||
except Exception:
|
||||
try:
|
||||
detail = musicbrainzngs.get_release_group_by_id(rgid, includes=["tags"])
|
||||
except Exception:
|
||||
return None
|
||||
rg = detail.get("release-group") or {}
|
||||
return _best_genre(rg.get("genre-list")) or _best_genre(rg.get("tag-list"))
|
||||
|
||||
|
||||
def _genre_from_artist(mbid: str) -> str | None:
|
||||
try:
|
||||
detail = musicbrainzngs.get_artist_by_id(mbid, includes=["genres"])
|
||||
except Exception:
|
||||
try:
|
||||
detail = musicbrainzngs.get_artist_by_id(mbid, includes=["tags"])
|
||||
except Exception:
|
||||
return None
|
||||
artist = detail.get("artist") or {}
|
||||
return _best_genre(artist.get("genre-list")) or _best_genre(artist.get("tag-list"))
|
||||
|
||||
|
||||
# ── junk frame classification ─────────────────────────────────────────────────
|
||||
|
||||
# ID3 (MP3): match by 4-char frame id prefix. TXXX/PRIV are handled specially.
|
||||
_ID3_JUNK_PREFIXES = (
|
||||
"COMM", # comments
|
||||
"USLT", "SYLT", # embedded lyrics
|
||||
"WXXX", "WCOM", "WCOP", "WOAF", "WOAR", "WOAS", "WORS", "WPAY", "WPUB", # URLs
|
||||
"TENC", "TSSE", # encoded-by / encoder settings
|
||||
)
|
||||
# TXXX descriptions worth dropping (store/encoder junk). ReplayGain is preserved.
|
||||
_ID3_TXXX_JUNK = ("itun", "cddb", "purchase", "comment", "encoder", "encoded by", "www", "url")
|
||||
|
||||
# Vorbis (FLAC/Ogg): lowercase comment keys.
|
||||
_VORBIS_JUNK_KEYS = {
|
||||
"comment", "comments", "description",
|
||||
"lyrics", "unsyncedlyrics", "unsynced lyrics",
|
||||
"encoder", "encodedby", "encoded_by", "encoder_options", "encoding", "tool",
|
||||
}
|
||||
_VORBIS_JUNK_SUBSTRINGS = ("url", "www", "purchase", "itun", "cddb")
|
||||
|
||||
# MP4 (M4A): atom keys.
|
||||
_MP4_JUNK_KEYS = {
|
||||
"\xa9cmt", # comment
|
||||
"\xa9lyr", # lyrics
|
||||
"\xa9too", "tool", # encoder
|
||||
"purd", # purchase date
|
||||
}
|
||||
|
||||
# Embedded artwork is NEVER stripped. These are belt-and-braces guards so no
|
||||
# current or future junk rule can ever match a picture frame/atom/comment.
|
||||
_ID3_PROTECTED = ("APIC", "PIC") # ID3v2.3/2.4 and ID3v2.2 attached pictures
|
||||
_MP4_PROTECTED = {"covr"} # MP4 cover atom
|
||||
_VORBIS_PROTECTED = {"metadata_block_picture", "coverart", "cover art"} # FLAC/Ogg art
|
||||
|
||||
|
||||
def _clean_label(text: str) -> str:
|
||||
"""A short, printable tag name for logs.
|
||||
|
||||
ID3 ``PRIV``/``COMM``/``TXXX`` dict keys embed the frame's raw payload (which
|
||||
can be arbitrary binary — Traktor blobs, embedded art, etc.). Strip anything
|
||||
non-printable and truncate so the activity log stays readable.
|
||||
"""
|
||||
text = "".join(ch for ch in str(text) if ch.isprintable()).strip()
|
||||
return (text[:45] + "…") if len(text) > 46 else text
|
||||
|
||||
|
||||
def _id3_frame_label(key: str, frame) -> str:
|
||||
"""Human-readable id for an ID3 frame, never including its binary data."""
|
||||
fid = getattr(frame, "FrameID", None) or key.split(":", 1)[0]
|
||||
if fid == "PRIV":
|
||||
owner = getattr(frame, "owner", "") or ""
|
||||
return _clean_label(f"PRIV:{owner}" if owner else "PRIV")
|
||||
if fid in ("TXXX", "COMM", "USLT", "SYLT", "WXXX"):
|
||||
desc = getattr(frame, "desc", "") or ""
|
||||
return _clean_label(f"{fid}:{desc}" if desc else fid)
|
||||
return _clean_label(fid)
|
||||
|
||||
|
||||
def _strip_id3_junk(tags: ID3) -> list[str]:
|
||||
removed: list[str] = []
|
||||
for key in list(tags.keys()):
|
||||
if key.startswith(_ID3_PROTECTED):
|
||||
continue # never touch embedded artwork (APIC/PIC)
|
||||
drop = key.startswith(_ID3_JUNK_PREFIXES)
|
||||
if not drop and key.startswith("TXXX:"):
|
||||
desc = key.split(":", 1)[1].lower()
|
||||
drop = any(token in desc for token in _ID3_TXXX_JUNK)
|
||||
if not drop and key.startswith("PRIV"):
|
||||
drop = True # Windows Media / player breadcrumbs
|
||||
if drop:
|
||||
removed.append(_id3_frame_label(key, tags[key]))
|
||||
del tags[key]
|
||||
return removed
|
||||
|
||||
|
||||
def _strip_vorbis_junk(tags) -> list[str]:
|
||||
removed: list[str] = []
|
||||
for key in list(tags.keys()):
|
||||
low = key.lower()
|
||||
if low in _VORBIS_PROTECTED:
|
||||
continue # never touch embedded artwork
|
||||
if low in _VORBIS_JUNK_KEYS or any(token in low for token in _VORBIS_JUNK_SUBSTRINGS):
|
||||
del tags[key]
|
||||
removed.append(_clean_label(key))
|
||||
return removed
|
||||
|
||||
|
||||
def _strip_mp4_junk(tags: MP4Tags) -> list[str]:
|
||||
removed: list[str] = []
|
||||
for key in list(tags.keys()):
|
||||
if key in _MP4_PROTECTED:
|
||||
continue # never touch the cover atom
|
||||
drop = key in _MP4_JUNK_KEYS
|
||||
if not drop and key.startswith("----"):
|
||||
low = key.lower()
|
||||
drop = any(token in low for token in ("itun", "purchase", "url", "www", "comment"))
|
||||
if drop:
|
||||
del tags[key]
|
||||
removed.append(_clean_label(key))
|
||||
return removed
|
||||
|
||||
|
||||
# ── track-number normalization ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _normalize_id3_tracks(tags: ID3) -> list[str]:
|
||||
changes: list[str] = []
|
||||
for frame_id, label in (("TRCK", "track"), ("TPOS", "disc")):
|
||||
frame = tags.get(frame_id)
|
||||
if frame is None:
|
||||
continue
|
||||
current = str(frame.text[0]) if frame.text else ""
|
||||
normalized = clean_track_number(current)
|
||||
if normalized and normalized != current:
|
||||
frame.text = [normalized]
|
||||
prefix = "" if label == "track" else f"{label} "
|
||||
changes.append(f"{prefix}{current} → {normalized}")
|
||||
return changes
|
||||
|
||||
|
||||
def _normalize_vorbis_tracks(tags) -> list[str]:
|
||||
changes: list[str] = []
|
||||
for key, label in (("tracknumber", "track"), ("discnumber", "disc")):
|
||||
values = tags.get(key)
|
||||
if not values:
|
||||
continue
|
||||
current = str(values[0])
|
||||
normalized = clean_track_number(current)
|
||||
if normalized and normalized != current:
|
||||
tags[key] = [normalized]
|
||||
prefix = "" if label == "track" else f"{label} "
|
||||
changes.append(f"{prefix}{current} → {normalized}")
|
||||
return changes
|
||||
|
||||
|
||||
# ── per-file processing ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _set_genre(audio, tags, genre: str) -> bool:
|
||||
"""Write ``genre`` across formats. Returns True if it changed."""
|
||||
if isinstance(tags, ID3):
|
||||
from mutagen.id3 import TCON
|
||||
|
||||
existing = tags.get("TCON")
|
||||
if existing is not None and existing.text == [genre]:
|
||||
return False
|
||||
tags.setall("TCON", [TCON(encoding=3, text=[genre])])
|
||||
return True
|
||||
if isinstance(tags, MP4Tags):
|
||||
if tags.get("\xa9gen") == [genre]:
|
||||
return False
|
||||
tags["\xa9gen"] = [genre]
|
||||
return True
|
||||
# Vorbis comment (FLAC/Ogg)
|
||||
if list(tags.get("genre", [])) == [genre]:
|
||||
return False
|
||||
tags["genre"] = [genre]
|
||||
return True
|
||||
|
||||
|
||||
def _process_file(
|
||||
path: Path,
|
||||
opts: MetadataOptions,
|
||||
genre: str | None,
|
||||
rec: _Recorder,
|
||||
*,
|
||||
group: str,
|
||||
subgroup: str,
|
||||
) -> None:
|
||||
try:
|
||||
audio = MutagenFile(path)
|
||||
except (MutagenError, OSError) as exc:
|
||||
rec.emit("warn", "tag", f"Could not read {path.name}: {exc}")
|
||||
return
|
||||
if audio is None:
|
||||
return
|
||||
if audio.tags is None:
|
||||
try:
|
||||
audio.add_tags()
|
||||
except (MutagenError, Exception):
|
||||
rec.emit("skip", "tag", f"No tags and none could be added: {path.name}")
|
||||
return
|
||||
tags = audio.tags
|
||||
|
||||
removed: list[str] = []
|
||||
track_changes: list[str] = []
|
||||
genre_set = False
|
||||
|
||||
if opts.strip_junk:
|
||||
if isinstance(tags, ID3):
|
||||
removed = _strip_id3_junk(tags)
|
||||
elif isinstance(tags, MP4Tags):
|
||||
removed = _strip_mp4_junk(tags)
|
||||
else:
|
||||
removed = _strip_vorbis_junk(tags)
|
||||
|
||||
if opts.normalize_tracks:
|
||||
if isinstance(tags, ID3):
|
||||
track_changes = _normalize_id3_tracks(tags)
|
||||
elif isinstance(tags, MP4Tags):
|
||||
track_changes = [] # MP4 stores track as an integer tuple; nothing to pad
|
||||
else:
|
||||
track_changes = _normalize_vorbis_tracks(tags)
|
||||
|
||||
if opts.genres and genre and _set_genre(audio, tags, genre):
|
||||
genre_set = True
|
||||
|
||||
if not (removed or track_changes or genre_set):
|
||||
return
|
||||
|
||||
# Human-readable summary (also used by the collapsible raw log).
|
||||
parts: list[str] = []
|
||||
if removed:
|
||||
parts.append(f"strip {len(removed)} junk tag(s): {', '.join(removed[:6])}")
|
||||
if track_changes:
|
||||
parts.append("; ".join(track_changes))
|
||||
if genre_set:
|
||||
parts.append(f"genre → {genre}")
|
||||
|
||||
rec.emit(
|
||||
"dry" if opts.dry_run else "ok",
|
||||
"tag",
|
||||
f"{path.name}: " + " | ".join(parts),
|
||||
path=str(path),
|
||||
file=path.name,
|
||||
group=group,
|
||||
subgroup=subgroup,
|
||||
junk=removed,
|
||||
track="; ".join(track_changes) or None,
|
||||
genre=genre if genre_set else None,
|
||||
)
|
||||
if opts.dry_run:
|
||||
return
|
||||
try:
|
||||
audio.save()
|
||||
except (MutagenError, OSError) as exc:
|
||||
rec.emit("warn", "tag", f"Could not save {path.name}: {exc}")
|
||||
|
||||
|
||||
def _process_album(
|
||||
album_folder: Path,
|
||||
opts: MetadataOptions,
|
||||
rec: _Recorder,
|
||||
genre_cache: dict[str, str | None],
|
||||
overrides: dict[str, str],
|
||||
) -> None:
|
||||
audio_files = [
|
||||
f for f in album_folder.iterdir()
|
||||
if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS
|
||||
]
|
||||
if not audio_files:
|
||||
return
|
||||
|
||||
group = album_folder.name
|
||||
subgroup = album_folder.parent.name
|
||||
|
||||
genre = None
|
||||
if opts.genres:
|
||||
artist, album, _year = get_album_metadata_from_files(album_folder)
|
||||
if artist and album:
|
||||
group = album
|
||||
subgroup = artist
|
||||
if artist:
|
||||
genre = _resolve_artist_genre(artist, album, genre_cache, overrides, rec)
|
||||
|
||||
for file in audio_files:
|
||||
_process_file(file, opts, genre, rec, group=group, subgroup=subgroup)
|
||||
|
||||
|
||||
def _resolve_artist_genre(
|
||||
artist: str,
|
||||
album: str | None,
|
||||
cache: dict[str, str | None],
|
||||
overrides: dict[str, str],
|
||||
rec: _Recorder,
|
||||
) -> str | None:
|
||||
"""One genre per artist, resolved once and cached for the whole run.
|
||||
|
||||
Priority: a user override (always wins, no lookup) → the artist's MusicBrainz
|
||||
genre → one album lookup as fallback. The result is cached under the artist so
|
||||
every later album by the same artist reuses it and the library stays consistent.
|
||||
"""
|
||||
key = artist.strip().lower()
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
|
||||
override = overrides.get(key)
|
||||
if override:
|
||||
cache[key] = override
|
||||
rec.emit("info", "genre", f"{artist} → {override} (override)")
|
||||
return override
|
||||
|
||||
genre = find_artist_genre(artist)
|
||||
if not genre and album:
|
||||
genre = find_album_genre(artist, album)
|
||||
cache[key] = genre
|
||||
|
||||
if genre:
|
||||
rec.emit("info", "genre", f"{artist} → {genre}")
|
||||
else:
|
||||
rec.emit("skip", "genre", f"No MusicBrainz genre: {artist}")
|
||||
return genre
|
||||
|
||||
|
||||
def process_library(
|
||||
options: MetadataOptions,
|
||||
*,
|
||||
root: Path | None = None,
|
||||
log: LogCallback | None = None,
|
||||
album_paths: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Run the selected tag-maintenance modes. Honours ``options.dry_run``."""
|
||||
root = root or MUSIC_ROOT
|
||||
rec = _Recorder(sink=log)
|
||||
|
||||
if not options.any_mode:
|
||||
rec.emit("warn", "start", "No metadata modes selected.")
|
||||
return {"root": str(root), "dry_run": options.dry_run, "actions": rec.actions, "counts": rec.counts}
|
||||
|
||||
if not root.exists():
|
||||
rec.emit("warn", "root", f"Music root does not exist: {root}")
|
||||
return {"root": str(root), "dry_run": options.dry_run, "actions": rec.actions, "counts": rec.counts}
|
||||
|
||||
if album_paths:
|
||||
wanted = {str(Path(p)) for p in album_paths}
|
||||
folders = [Path(p) for p in album_paths] if all(Path(p).exists() for p in album_paths) else [
|
||||
f for f in _iter_album_folders(root) if str(f) in wanted
|
||||
]
|
||||
else:
|
||||
folders = list(_iter_album_folders(root))
|
||||
|
||||
if options.recent_only and not album_paths:
|
||||
before = len(folders)
|
||||
folders = [f for f in folders if _was_created_recently(f, options.recent_window_seconds)]
|
||||
rec.emit(
|
||||
"info",
|
||||
"recent",
|
||||
f"Recent-only: {len(folders)} of {before} albums modified in the last "
|
||||
f"{options.recent_window_seconds // 3600}h",
|
||||
)
|
||||
|
||||
if options.genres and not _HAS_MUSICBRAINZ:
|
||||
rec.emit("warn", "genre", "MusicBrainz library not available — genre lookups skipped.")
|
||||
|
||||
rec.emit(
|
||||
"info",
|
||||
"start",
|
||||
f"{'Dry run' if options.dry_run else 'Applying'} tag maintenance across {len(folders)} album(s)",
|
||||
)
|
||||
genre_cache: dict[str, str | None] = {} # one genre per artist, for the whole run
|
||||
overrides = _overrides_map() if options.genres else {}
|
||||
for album_folder in folders:
|
||||
try:
|
||||
_process_album(album_folder, options, rec, genre_cache, overrides)
|
||||
except OSError as exc:
|
||||
rec.emit("warn", "album", f"Error processing {album_folder.name}: {exc}")
|
||||
|
||||
rec.emit("info", "done", "Finished")
|
||||
return {
|
||||
"root": str(root),
|
||||
"dry_run": options.dry_run,
|
||||
"actions": rec.actions,
|
||||
"counts": rec.counts,
|
||||
}
|
||||
|
||||
|
||||
def process_library_stream(
|
||||
options: MetadataOptions,
|
||||
*,
|
||||
root: Path | None = None,
|
||||
album_paths: list[str] | None = None,
|
||||
) -> Iterator[dict]:
|
||||
"""Run tag maintenance and yield each action the moment it happens."""
|
||||
events: queue.Queue = queue.Queue()
|
||||
sentinel = object()
|
||||
|
||||
def worker():
|
||||
try:
|
||||
process_library(options, root=root, log=events.put, album_paths=album_paths)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
events.put({"level": "warn", "action": "error", "message": str(exc)})
|
||||
finally:
|
||||
events.put(sentinel)
|
||||
|
||||
threading.Thread(target=worker, name="music_metadata_stream", daemon=True).start()
|
||||
while True:
|
||||
item = events.get()
|
||||
if item is sentinel:
|
||||
break
|
||||
yield item
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -115,22 +116,49 @@ def _map_album(raw: dict) -> dict:
|
||||
|
||||
|
||||
def _map_song(raw: dict) -> dict:
|
||||
play_count = raw.get("playCount")
|
||||
try:
|
||||
play_count = int(play_count) if play_count is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
play_count = 0
|
||||
return {
|
||||
"id": raw.get("id"),
|
||||
"title": raw.get("title") or "",
|
||||
"track": raw.get("track"),
|
||||
"disc": raw.get("discNumber"),
|
||||
"artist": raw.get("artist") or "",
|
||||
"artist_id": raw.get("artistId"),
|
||||
"album": raw.get("album") or "",
|
||||
"album_id": raw.get("albumId") or raw.get("parent"),
|
||||
"year": raw.get("year"),
|
||||
"genre": raw.get("genre"),
|
||||
"duration": raw.get("duration") or 0,
|
||||
"bitrate": raw.get("bitRate"),
|
||||
"suffix": raw.get("suffix"),
|
||||
"size": raw.get("size"),
|
||||
"path": raw.get("path"),
|
||||
"cover_art": raw.get("coverArt"),
|
||||
"cover_url": _cover_url(raw.get("coverArt")),
|
||||
"play_count": play_count,
|
||||
"created": raw.get("created"),
|
||||
"starred": raw.get("starred"),
|
||||
}
|
||||
|
||||
|
||||
def _parse_dt(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _dt_timestamp(value: str | None) -> float:
|
||||
parsed = _parse_dt(value)
|
||||
return parsed.timestamp() if parsed else 0.0
|
||||
|
||||
|
||||
async def ping(client: httpx.AsyncClient) -> dict:
|
||||
if not is_configured():
|
||||
return {"connected": False, "configured": False, "url": NAVIDROME_URL}
|
||||
@@ -205,6 +233,64 @@ async def get_album(client: httpx.AsyncClient, album_id: str) -> dict:
|
||||
return album
|
||||
|
||||
|
||||
async def search_songs(client: httpx.AsyncClient, query: str, *, count: int = 100, offset: int = 0) -> list[dict]:
|
||||
payload = await _call(
|
||||
client,
|
||||
"search3",
|
||||
{"query": query, "artistCount": 0, "albumCount": 0, "songCount": count, "songOffset": offset},
|
||||
)
|
||||
raw_songs = ((payload.get("searchResult3") or {}).get("song")) or []
|
||||
return [_map_song(song) for song in raw_songs]
|
||||
|
||||
|
||||
async def get_now_playing(client: httpx.AsyncClient) -> list[dict]:
|
||||
payload = await _call(client, "getNowPlaying")
|
||||
raw = ((payload.get("nowPlaying") or {}).get("entry")) or []
|
||||
return [_map_song(song) for song in raw]
|
||||
|
||||
|
||||
async def get_starred(client: httpx.AsyncClient) -> dict:
|
||||
payload = await _call(client, "getStarred2")
|
||||
raw = payload.get("starred2") or {}
|
||||
return {
|
||||
"songs": [_map_song(song) for song in (raw.get("song") or [])],
|
||||
"albums": [_map_album(album) for album in (raw.get("album") or [])],
|
||||
"artists": [
|
||||
{
|
||||
"id": artist.get("id"),
|
||||
"name": artist.get("name") or "",
|
||||
"cover_art": artist.get("coverArt"),
|
||||
"cover_url": _cover_url(artist.get("coverArt")),
|
||||
"starred": artist.get("starred"),
|
||||
}
|
||||
for artist in (raw.get("artist") or [])
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def get_playlists(client: httpx.AsyncClient) -> list[dict]:
|
||||
payload = await _call(client, "getPlaylists")
|
||||
raw = ((payload.get("playlists") or {}).get("playlist")) or []
|
||||
playlists = [
|
||||
{
|
||||
"id": p.get("id"),
|
||||
"name": p.get("name") or "",
|
||||
"song_count": p.get("songCount") or 0,
|
||||
"owner": p.get("owner") or "",
|
||||
"public": bool(p.get("public")),
|
||||
"duration": p.get("duration") or 0,
|
||||
"changed": p.get("changed"),
|
||||
}
|
||||
for p in raw
|
||||
]
|
||||
playlists.sort(key=lambda entry: entry["name"].lower())
|
||||
return playlists
|
||||
|
||||
|
||||
async def delete_playlist(client: httpx.AsyncClient, playlist_id: str) -> None:
|
||||
await _call(client, "deletePlaylist", {"id": playlist_id})
|
||||
|
||||
|
||||
async def get_cover_art(
|
||||
client: httpx.AsyncClient, cover_id: str, size: int | None = None
|
||||
) -> tuple[bytes, str]:
|
||||
@@ -306,3 +392,74 @@ async def get_stats(client: httpx.AsyncClient) -> dict:
|
||||
"genre_count": len(genres),
|
||||
"top_genres": genres[:8],
|
||||
}
|
||||
|
||||
|
||||
async def get_reporting_snapshot(
|
||||
client: httpx.AsyncClient, *, page_size: int = 500, max_pages: int = 400
|
||||
) -> dict:
|
||||
stats = await get_stats(client)
|
||||
# Keep the API calls explicit so they remain easy to debug.
|
||||
top_albums = await get_albums(client, list_type="frequent", size=12)
|
||||
recent_albums = await get_albums(client, list_type="recent", size=12)
|
||||
newest_albums = await get_albums(client, list_type="newest", size=12)
|
||||
starred = await get_starred(client)
|
||||
now_playing = await get_now_playing(client)
|
||||
|
||||
all_songs: list[dict] = []
|
||||
offset = 0
|
||||
scanned_pages = 0
|
||||
truncated = False
|
||||
for _ in range(max_pages):
|
||||
scanned_pages += 1
|
||||
songs = await search_songs(client, "", count=page_size, offset=offset)
|
||||
if not songs:
|
||||
break
|
||||
all_songs.extend(songs)
|
||||
if len(songs) < page_size:
|
||||
break
|
||||
offset += page_size
|
||||
else:
|
||||
truncated = True
|
||||
|
||||
songs_with_plays = [song for song in all_songs if (song.get("play_count") or 0) > 0]
|
||||
total_play_count = sum(song.get("play_count") or 0 for song in songs_with_plays)
|
||||
top_tracks = sorted(
|
||||
songs_with_plays,
|
||||
key=lambda song: ((song.get("play_count") or 0), song.get("title") or "", song.get("artist") or ""),
|
||||
reverse=True,
|
||||
)[:25]
|
||||
recently_added_tracks = sorted(
|
||||
[song for song in all_songs if song.get("created")],
|
||||
key=lambda song: _dt_timestamp(song.get("created")),
|
||||
reverse=True,
|
||||
)[:25]
|
||||
favorite_tracks = sorted(
|
||||
starred["songs"],
|
||||
key=lambda song: ((song.get("play_count") or 0), _dt_timestamp(song.get("starred"))),
|
||||
reverse=True,
|
||||
)[:25]
|
||||
|
||||
return {
|
||||
"summary": {
|
||||
**stats,
|
||||
"library_tracks_scanned": len(all_songs),
|
||||
"tracks_with_plays": len(songs_with_plays),
|
||||
"total_play_count": total_play_count,
|
||||
"favorite_song_count": len(starred["songs"]),
|
||||
"favorite_album_count": len(starred["albums"]),
|
||||
"favorite_artist_count": len(starred["artists"]),
|
||||
"now_playing_count": len(now_playing),
|
||||
"scan_pages": scanned_pages,
|
||||
"truncated": truncated,
|
||||
},
|
||||
"top_tracks": top_tracks,
|
||||
"favorite_tracks": favorite_tracks,
|
||||
"recently_added_tracks": recently_added_tracks,
|
||||
"top_albums": top_albums,
|
||||
"recent_albums": recent_albums,
|
||||
"newest_albums": newest_albums,
|
||||
"top_genres": stats["top_genres"],
|
||||
"now_playing": now_playing,
|
||||
"favorite_albums": starred["albums"][:20],
|
||||
"favorite_artists": starred["artists"][:20],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from stat import S_ISDIR
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import paramiko
|
||||
except ImportError: # pragma: no cover - exercised indirectly via status checks
|
||||
paramiko = None
|
||||
|
||||
from services import settings as settings_service
|
||||
|
||||
DEFAULT_REMOTE_APP_DIR = "/share/Docker/homelabtoolkit"
|
||||
DEPLOY_TIMEOUT_SECONDS = int(os.environ.get("DEPLOY_TIMEOUT_SECONDS", "1800"))
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||
REMOTE_CONTAINER_NAME = os.environ.get("DEPLOY_CONTAINER_NAME", "homelabtoolkit").strip() or "homelabtoolkit"
|
||||
|
||||
TOP_LEVEL_FILES = ("app.py", "rotate_preroll.py", "Dockerfile", "docker-compose.yml", "requirements.txt")
|
||||
FRONTEND_ROOT_FILES = ("package.json", "package-lock.json", "vite.config.ts", "tsconfig.json", "index.html")
|
||||
LOGO_EXTENSIONS = {".png", ".jpg", ".jpeg"}
|
||||
|
||||
runtime: dict[str, Any] = {
|
||||
"running": False,
|
||||
"last_started_at": None,
|
||||
"last_finished_at": None,
|
||||
"last_status": "idle",
|
||||
"last_message": None,
|
||||
"last_output_tail": [],
|
||||
}
|
||||
_lock = asyncio.Lock()
|
||||
_task: asyncio.Task | None = None
|
||||
|
||||
logging.getLogger("paramiko").setLevel(logging.WARNING)
|
||||
logger = logging.getLogger("homelabtoolkit.update")
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def normalize_client_host(value: str | None) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if "," in text:
|
||||
text = text.split(",", 1)[0].strip()
|
||||
if text.startswith("::ffff:"):
|
||||
text = text[len("::ffff:") :]
|
||||
return text
|
||||
|
||||
|
||||
def is_local_client(host: str | None) -> bool:
|
||||
text = normalize_client_host(host)
|
||||
if not text:
|
||||
return False
|
||||
if text.lower() == "localhost":
|
||||
return True
|
||||
try:
|
||||
ip = ipaddress.ip_address(text)
|
||||
except ValueError:
|
||||
return False
|
||||
return ip.is_loopback or ip.is_private
|
||||
|
||||
|
||||
def is_paramiko_available() -> bool:
|
||||
return paramiko is not None
|
||||
|
||||
|
||||
def is_configured(values: dict[str, Any]) -> bool:
|
||||
return bool(str(values.get("deploy_nas_host") or "").strip() and str(values.get("deploy_nas_user") or "").strip())
|
||||
|
||||
|
||||
def remote_app_dir(values: dict[str, Any]) -> str:
|
||||
return str(values.get("deploy_remote_app_dir") or DEFAULT_REMOTE_APP_DIR).strip() or DEFAULT_REMOTE_APP_DIR
|
||||
|
||||
|
||||
def deploy_password(values: dict[str, Any]) -> str:
|
||||
return str(values.get("deploy_nas_password") or "")
|
||||
|
||||
|
||||
def using_saved_password(values: dict[str, Any]) -> bool:
|
||||
return bool(deploy_password(values))
|
||||
|
||||
|
||||
def deploy_music_host_path(values: dict[str, Any]) -> str:
|
||||
return str(values.get("deploy_music_host_path") or "/share/Music").strip() or "/share/Music"
|
||||
|
||||
|
||||
def _remote(values: dict[str, Any]) -> str:
|
||||
return f'{str(values.get("deploy_nas_user") or "").strip()}@{str(values.get("deploy_nas_host") or "").strip()}'
|
||||
|
||||
|
||||
def _tail(lines: list[str], max_lines: int = 40) -> list[str]:
|
||||
return lines[-max_lines:]
|
||||
|
||||
|
||||
def _push_runtime_output(log_lines: list[str]) -> None:
|
||||
runtime["last_output_tail"] = list(_tail(log_lines))
|
||||
|
||||
|
||||
def _log(log_lines: list[str], message: str) -> None:
|
||||
text = message.rstrip()
|
||||
if not text:
|
||||
return
|
||||
log_lines.append(text)
|
||||
_push_runtime_output(log_lines)
|
||||
logger.info(text)
|
||||
|
||||
|
||||
def _ensure_remote_dir(sftp, remote_dir: str) -> None:
|
||||
normalized = posixpath.normpath(remote_dir)
|
||||
parts = [part for part in normalized.split("/") if part]
|
||||
current = "/" if normalized.startswith("/") else ""
|
||||
for part in parts:
|
||||
current = posixpath.join(current, part) if current not in ("", "/") else f"{current}{part}" if current == "/" else part
|
||||
try:
|
||||
attrs = sftp.stat(current)
|
||||
if not S_ISDIR(attrs.st_mode):
|
||||
raise RuntimeError(f"Remote path exists but is not a directory: {current}")
|
||||
except OSError:
|
||||
sftp.mkdir(current)
|
||||
|
||||
|
||||
def _upload_file(sftp, local_path: Path, remote_path: str, log_lines: list[str]) -> None:
|
||||
_ensure_remote_dir(sftp, posixpath.dirname(remote_path))
|
||||
sftp.put(str(local_path), remote_path)
|
||||
_log(log_lines, f"Uploaded {local_path.relative_to(ROOT_DIR).as_posix()} -> {remote_path}")
|
||||
|
||||
|
||||
def _upload_text(sftp, content: str, remote_path: str, label: str, log_lines: list[str]) -> None:
|
||||
_ensure_remote_dir(sftp, posixpath.dirname(remote_path))
|
||||
with sftp.file(remote_path, "w") as handle:
|
||||
handle.write(content)
|
||||
_log(log_lines, f"Rendered {label} -> {remote_path}")
|
||||
|
||||
|
||||
def _upload_tree(sftp, local_dir: Path, remote_dir: str, log_lines: list[str]) -> None:
|
||||
if not local_dir.exists():
|
||||
return
|
||||
_ensure_remote_dir(sftp, remote_dir)
|
||||
for path in sorted(local_dir.rglob("*")):
|
||||
if path.is_dir():
|
||||
_ensure_remote_dir(sftp, posixpath.join(remote_dir, path.relative_to(local_dir).as_posix()))
|
||||
continue
|
||||
if "__pycache__" in path.parts:
|
||||
continue
|
||||
remote_path = posixpath.join(remote_dir, path.relative_to(local_dir).as_posix())
|
||||
_upload_file(sftp, path, remote_path, log_lines)
|
||||
|
||||
|
||||
def _stream_output(channel, log_lines: list[str]) -> None:
|
||||
stdout_buffer = ""
|
||||
stderr_buffer = ""
|
||||
while True:
|
||||
had_output = False
|
||||
if channel.recv_ready():
|
||||
stdout_buffer += channel.recv(4096).decode("utf-8", errors="replace")
|
||||
had_output = True
|
||||
while "\n" in stdout_buffer:
|
||||
line, stdout_buffer = stdout_buffer.split("\n", 1)
|
||||
_log(log_lines, line)
|
||||
if channel.recv_stderr_ready():
|
||||
stderr_buffer += channel.recv_stderr(4096).decode("utf-8", errors="replace")
|
||||
had_output = True
|
||||
while "\n" in stderr_buffer:
|
||||
line, stderr_buffer = stderr_buffer.split("\n", 1)
|
||||
_log(log_lines, line)
|
||||
if channel.exit_status_ready() and not channel.recv_ready() and not channel.recv_stderr_ready():
|
||||
break
|
||||
if not had_output:
|
||||
time.sleep(0.1)
|
||||
|
||||
if stdout_buffer.strip():
|
||||
_log(log_lines, stdout_buffer)
|
||||
if stderr_buffer.strip():
|
||||
_log(log_lines, stderr_buffer)
|
||||
|
||||
|
||||
def _run_remote_command(client, command: str, log_lines: list[str], allow_failure: bool = False) -> tuple[int, list[str]]:
|
||||
stdin, stdout, stderr = client.exec_command(command, timeout=DEPLOY_TIMEOUT_SECONDS)
|
||||
if stdin:
|
||||
stdin.close()
|
||||
channel = stdout.channel
|
||||
command_lines: list[str] = []
|
||||
_stream_output(channel, command_lines)
|
||||
for line in command_lines:
|
||||
_log(log_lines, line)
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
if exit_code != 0 and not allow_failure:
|
||||
raise RuntimeError(f"Remote command failed with exit code {exit_code}")
|
||||
return exit_code, command_lines
|
||||
|
||||
|
||||
def _connect(values: dict[str, Any]):
|
||||
if paramiko is None:
|
||||
raise RuntimeError("Paramiko is not installed.")
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
connect_kwargs: dict[str, Any] = {
|
||||
"hostname": str(values.get("deploy_nas_host") or "").strip(),
|
||||
"username": str(values.get("deploy_nas_user") or "").strip(),
|
||||
"timeout": 20,
|
||||
"banner_timeout": 20,
|
||||
"auth_timeout": 20,
|
||||
}
|
||||
password = deploy_password(values)
|
||||
if password:
|
||||
connect_kwargs["password"] = password
|
||||
connect_kwargs["look_for_keys"] = False
|
||||
connect_kwargs["allow_agent"] = False
|
||||
client.connect(**connect_kwargs)
|
||||
return client
|
||||
|
||||
|
||||
def render_remote_compose(values: dict[str, Any]) -> str:
|
||||
source = (ROOT_DIR / "docker-compose.yml").read_text(encoding="utf-8")
|
||||
remote_dir = remote_app_dir(values).rstrip("/")
|
||||
music_host = deploy_music_host_path(values)
|
||||
rendered = source
|
||||
rendered = rendered.replace("/share/Docker/homelabtoolkit/output:/app/output", f"{remote_dir}/output:/app/output")
|
||||
rendered = rendered.replace("/share/Docker/homelabtoolkit/cache:/app/cache", f"{remote_dir}/cache:/app/cache")
|
||||
rendered = rendered.replace("/share/Music:/music", f"{music_host}:/music")
|
||||
return rendered
|
||||
|
||||
|
||||
def render_remote_settings(values: dict[str, Any]) -> str:
|
||||
payload = {key: values.get(key) for key in settings_service.FIELD_SPECS if key in values}
|
||||
return json.dumps(payload, indent=2)
|
||||
|
||||
|
||||
def _check_container_health(client, base_dir: str, log_lines: list[str]) -> tuple[bool, str]:
|
||||
inspect_cmd = (
|
||||
f"docker inspect -f '{{{{.State.Status}}}}|{{{{.State.Running}}}}|{{{{.State.ExitCode}}}}|{{{{.State.Error}}}}' "
|
||||
f"{REMOTE_CONTAINER_NAME} 2>/dev/null || true"
|
||||
)
|
||||
_, lines = _run_remote_command(client, inspect_cmd, log_lines, allow_failure=True)
|
||||
raw = (lines[-1] if lines else "").strip()
|
||||
if not raw:
|
||||
_log(log_lines, f"Container {REMOTE_CONTAINER_NAME} was not found after deploy.")
|
||||
return False, "container missing"
|
||||
|
||||
parts = raw.split("|", 3)
|
||||
state = parts[0] if len(parts) > 0 else "unknown"
|
||||
running = parts[1].lower() == "true" if len(parts) > 1 else False
|
||||
exit_code = parts[2] if len(parts) > 2 else ""
|
||||
error = parts[3].strip() if len(parts) > 3 else ""
|
||||
_log(log_lines, f"Container status: state={state} running={running} exit_code={exit_code or 'n/a'}")
|
||||
if running and state == "running":
|
||||
return True, state
|
||||
|
||||
_log(log_lines, "Container did not reach running state. Fetching recent logs…")
|
||||
_run_remote_command(client, f"docker logs --tail 80 {REMOTE_CONTAINER_NAME} 2>&1 || true", log_lines, allow_failure=True)
|
||||
if error:
|
||||
_log(log_lines, f"Container error: {error}")
|
||||
return False, state or "unknown"
|
||||
|
||||
|
||||
def status_payload(values: dict[str, Any], client_host: str | None) -> dict[str, Any]:
|
||||
allowed = is_local_client(client_host)
|
||||
configured = is_configured(values)
|
||||
transport_ready = is_paramiko_available()
|
||||
available = allowed and configured and transport_ready
|
||||
|
||||
reasons: list[str] = []
|
||||
if not allowed:
|
||||
reasons.append("Updates can only be triggered from a local/private network client.")
|
||||
if not configured:
|
||||
reasons.append("Set a NAS host and NAS user first.")
|
||||
if not transport_ready:
|
||||
reasons.append("Python SSH support is not installed on this host yet.")
|
||||
|
||||
return {
|
||||
"available": available,
|
||||
"allowed": allowed,
|
||||
"configured": configured,
|
||||
"transport": "paramiko" if transport_ready else None,
|
||||
"transport_ready": transport_ready,
|
||||
"password_configured": using_saved_password(values),
|
||||
"client_host": normalize_client_host(client_host),
|
||||
"nas_host": str(values.get("deploy_nas_host") or "").strip(),
|
||||
"nas_user": str(values.get("deploy_nas_user") or "").strip(),
|
||||
"remote_app_dir": remote_app_dir(values),
|
||||
"runtime": dict(runtime),
|
||||
"reason": " ".join(reasons).strip() or None,
|
||||
}
|
||||
|
||||
|
||||
def _run_sync(values: dict[str, Any]) -> dict[str, Any]:
|
||||
log_lines: list[str] = []
|
||||
client = None
|
||||
sftp = None
|
||||
try:
|
||||
_log(log_lines, f"Deploying HomelabToolkit to {_remote(values)}:{remote_app_dir(values)}")
|
||||
_log(
|
||||
log_lines,
|
||||
"Auth mode: saved password" if using_saved_password(values) else "Auth mode: SSH keys / agent"
|
||||
)
|
||||
_log(log_lines, "Connecting to remote host…")
|
||||
|
||||
client = _connect(values)
|
||||
_log(log_lines, "SSH connection established.")
|
||||
sftp = client.open_sftp()
|
||||
_log(log_lines, "SFTP channel opened.")
|
||||
|
||||
base_dir = remote_app_dir(values).rstrip("/")
|
||||
for directory in (
|
||||
base_dir,
|
||||
f"{base_dir}/output",
|
||||
f"{base_dir}/cache",
|
||||
f"{base_dir}/static",
|
||||
f"{base_dir}/static/studios",
|
||||
f"{base_dir}/services",
|
||||
f"{base_dir}/frontend",
|
||||
):
|
||||
_ensure_remote_dir(sftp, directory)
|
||||
_log(log_lines, "Remote directory structure ensured.")
|
||||
|
||||
for name in TOP_LEVEL_FILES:
|
||||
source = ROOT_DIR / name
|
||||
if source.exists():
|
||||
if name == "docker-compose.yml":
|
||||
_upload_text(sftp, render_remote_compose(values), f"{base_dir}/{name}", name, log_lines)
|
||||
else:
|
||||
_upload_file(sftp, source, f"{base_dir}/{name}", log_lines)
|
||||
|
||||
_upload_text(sftp, render_remote_settings(values), f"{base_dir}/cache/settings.json", "settings.json", log_lines)
|
||||
|
||||
_upload_tree(sftp, ROOT_DIR / "static", f"{base_dir}/static", log_lines)
|
||||
_upload_tree(sftp, ROOT_DIR / "services", f"{base_dir}/services", log_lines)
|
||||
|
||||
_log(log_lines, "Cleaning remote frontend build directories…")
|
||||
_run_remote_command(client, f"rm -rf {base_dir}/frontend/node_modules {base_dir}/frontend/dist", log_lines)
|
||||
for name in FRONTEND_ROOT_FILES:
|
||||
source = ROOT_DIR / "frontend" / name
|
||||
if source.exists():
|
||||
_upload_file(sftp, source, f"{base_dir}/frontend/{name}", log_lines)
|
||||
_upload_tree(sftp, ROOT_DIR / "frontend" / "src", f"{base_dir}/frontend/src", log_lines)
|
||||
|
||||
for path in sorted(ROOT_DIR.iterdir()):
|
||||
if path.is_file() and path.suffix.lower() in LOGO_EXTENSIONS:
|
||||
_upload_file(sftp, path, f"{base_dir}/{path.name}", log_lines)
|
||||
|
||||
_log(log_lines, "Starting remote docker compose build and update…")
|
||||
_run_remote_command(
|
||||
client,
|
||||
(
|
||||
f"cd {base_dir} && "
|
||||
"if command -v docker-compose >/dev/null 2>&1; then "
|
||||
"docker-compose build && docker-compose up -d; "
|
||||
"else "
|
||||
"docker compose build && docker compose up -d; "
|
||||
"fi"
|
||||
),
|
||||
log_lines,
|
||||
)
|
||||
|
||||
healthy, state = _check_container_health(client, base_dir, log_lines)
|
||||
if not healthy:
|
||||
return {
|
||||
"ok": False,
|
||||
"message": f"Deployment finished but container state is {state}.",
|
||||
"output_tail": _tail(log_lines),
|
||||
}
|
||||
|
||||
_log(log_lines, "Deployment complete.")
|
||||
_log(log_lines, f"To view logs: ssh {_remote(values)} 'cd {base_dir} && docker compose logs -f'")
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "Deployment completed.",
|
||||
"output_tail": _tail(log_lines),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"message": f"Deployment failed: {exc}",
|
||||
"output_tail": _tail(log_lines),
|
||||
}
|
||||
finally:
|
||||
if sftp is not None:
|
||||
sftp.close()
|
||||
if client is not None:
|
||||
client.close()
|
||||
|
||||
|
||||
async def run_update(values: dict[str, Any], client_host: str | None) -> dict[str, Any]:
|
||||
async with _lock:
|
||||
status = status_payload(values, client_host)
|
||||
if not status["allowed"]:
|
||||
return {"ok": False, "message": status["reason"] or "Update not allowed."}
|
||||
if not status["configured"]:
|
||||
return {"ok": False, "message": status["reason"] or "Update target is not configured."}
|
||||
if not status["transport_ready"]:
|
||||
return {"ok": False, "message": status["reason"] or "Python SSH support is not available."}
|
||||
|
||||
runtime["running"] = True
|
||||
runtime["last_started_at"] = _now()
|
||||
runtime["last_finished_at"] = None
|
||||
runtime["last_status"] = "running"
|
||||
runtime["last_message"] = None
|
||||
runtime["last_output_tail"] = []
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(asyncio.to_thread(_run_sync, values), timeout=DEPLOY_TIMEOUT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
result = {
|
||||
"ok": False,
|
||||
"message": f"Deployment timed out after {DEPLOY_TIMEOUT_SECONDS} seconds.",
|
||||
"output_tail": [],
|
||||
}
|
||||
|
||||
runtime["running"] = False
|
||||
runtime["last_finished_at"] = _now()
|
||||
runtime["last_status"] = "ok" if result.get("ok") else "error"
|
||||
runtime["last_message"] = result.get("message")
|
||||
runtime["last_output_tail"] = list(result.get("output_tail") or [])
|
||||
return result
|
||||
|
||||
|
||||
async def start_update(values: dict[str, Any], client_host: str | None) -> dict[str, Any]:
|
||||
global _task
|
||||
status = status_payload(values, client_host)
|
||||
if not status["allowed"]:
|
||||
return {"ok": False, "message": status["reason"] or "Update not allowed."}
|
||||
if not status["configured"]:
|
||||
return {"ok": False, "message": status["reason"] or "Update target is not configured."}
|
||||
if not status["transport_ready"]:
|
||||
return {"ok": False, "message": status["reason"] or "Python SSH support is not available."}
|
||||
if runtime["running"]:
|
||||
return {"ok": False, "message": "Deployment is already running."}
|
||||
_task = asyncio.create_task(run_update(values, client_host))
|
||||
return {"ok": True, "message": "Deployment started."}
|
||||
+193
-21
@@ -1,27 +1,111 @@
|
||||
"""Runtime settings store.
|
||||
|
||||
Configuration can come from two places: environment variables (the deploy-time
|
||||
defaults) and a JSON file written by the in-app Settings page. The file, when
|
||||
present, wins. ``load`` returns the effective settings; ``save`` persists the
|
||||
editable subset and returns the new effective settings.
|
||||
Settings can come from three places:
|
||||
1. environment variables (deploy-time defaults)
|
||||
2. a legacy JSON file (local/dev compatibility and migration source)
|
||||
3. PostgreSQL when ``DATABASE_URL`` is configured
|
||||
|
||||
When PostgreSQL is available, settings are persisted there and any legacy JSON
|
||||
settings are migrated on startup. The JSON file remains as a fallback for
|
||||
non-database local runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
SETTINGS_FILE = Path(os.environ.get("SETTINGS_FILE", "cache/settings.json"))
|
||||
try:
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
except ImportError: # pragma: no cover - exercised via runtime status instead
|
||||
psycopg = None
|
||||
dict_row = None
|
||||
|
||||
FIELDS = (
|
||||
"emby_url",
|
||||
"emby_api_key",
|
||||
"navidrome_url",
|
||||
"navidrome_user",
|
||||
"navidrome_password",
|
||||
"music_root",
|
||||
)
|
||||
from services import emby_tasks as emby_tasks_service
|
||||
|
||||
SETTINGS_FILE = Path(os.environ.get("SETTINGS_FILE", "cache/settings.json"))
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "").strip()
|
||||
|
||||
FIELD_SPECS = {
|
||||
"emby_url": "string",
|
||||
"emby_api_key": "string",
|
||||
"navidrome_url": "string",
|
||||
"navidrome_user": "string",
|
||||
"navidrome_password": "string",
|
||||
"audiobookshelf_url": "string",
|
||||
"audiobookshelf_token": "string",
|
||||
"music_root": "string",
|
||||
"homescreen_db_path": "string",
|
||||
"tmdb_api_key": "string",
|
||||
"deploy_nas_host": "string",
|
||||
"deploy_nas_user": "string",
|
||||
"deploy_nas_password": "string",
|
||||
"deploy_remote_app_dir": "string",
|
||||
"deploy_music_host_path": "string",
|
||||
"preroll_enabled": "bool",
|
||||
"preroll_active_dir": "string",
|
||||
"preroll_inactive_dir": "string",
|
||||
"preroll_state_file": "string",
|
||||
"preroll_weekday": "int",
|
||||
"preroll_time": "string",
|
||||
"emby_tasks": "object",
|
||||
}
|
||||
|
||||
FIELD_DEFAULTS = {
|
||||
"preroll_enabled": False,
|
||||
"preroll_weekday": 0,
|
||||
}
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value_json JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def _env(name: str, legacy_name: str | None = None, default: str = "") -> str:
|
||||
if os.environ.get(name) is not None:
|
||||
return os.environ[name]
|
||||
if legacy_name and os.environ.get(legacy_name) is not None:
|
||||
return os.environ[legacy_name]
|
||||
return default
|
||||
|
||||
|
||||
def _coerce_value(field: str, value):
|
||||
kind = FIELD_SPECS[field]
|
||||
if kind == "bool":
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||
if kind == "int":
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return FIELD_DEFAULTS.get(field, 0)
|
||||
if kind == "object":
|
||||
if field == "emby_tasks":
|
||||
return emby_tasks_service.normalize_settings(value)
|
||||
return value if isinstance(value, dict) else {}
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
|
||||
def using_postgres() -> bool:
|
||||
return bool(DATABASE_URL and psycopg is not None)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def postgres_connect():
|
||||
if not using_postgres():
|
||||
raise RuntimeError("PostgreSQL settings store is not configured.")
|
||||
with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
def env_defaults() -> dict:
|
||||
@@ -31,10 +115,30 @@ def env_defaults() -> dict:
|
||||
"navidrome_url": os.environ.get("NAVIDROME_URL", "http://10.0.0.2:4533"),
|
||||
"navidrome_user": os.environ.get("NAVIDROME_USER", ""),
|
||||
"navidrome_password": os.environ.get("NAVIDROME_PASSWORD", ""),
|
||||
"audiobookshelf_url": os.environ.get("AUDIOBOOKSHELF_URL", ""),
|
||||
"audiobookshelf_token": os.environ.get("AUDIOBOOKSHELF_TOKEN", ""),
|
||||
"music_root": os.environ.get("MUSIC_ROOT", r"\\Matt-htpc\d\Music"),
|
||||
"homescreen_db_path": os.environ.get("HOMESCREEN_DB_PATH", ""),
|
||||
"tmdb_api_key": os.environ.get("TMDB_API_KEY", ""),
|
||||
"deploy_nas_host": os.environ.get("DEPLOY_NAS_HOST", ""),
|
||||
"deploy_nas_user": os.environ.get("DEPLOY_NAS_USER", ""),
|
||||
"deploy_nas_password": os.environ.get("DEPLOY_NAS_PASSWORD", ""),
|
||||
"deploy_remote_app_dir": os.environ.get("DEPLOY_REMOTE_APP_DIR", "/share/Docker/homelabtoolkit"),
|
||||
"deploy_music_host_path": os.environ.get("DEPLOY_MUSIC_HOST_PATH", "/share/Music"),
|
||||
"preroll_enabled": _coerce_value("preroll_enabled", _env("PREROLL_ENABLED", default="false")),
|
||||
"preroll_active_dir": _env("PREROLL_ACTIVE_DIR", "ACTIVE_DIR", "/media/Prerolls"),
|
||||
"preroll_inactive_dir": _env("PREROLL_INACTIVE_DIR", "INACTIVE_DIR", "/media/Prerolls - Not Active"),
|
||||
"preroll_state_file": _env("PREROLL_STATE_FILE", "STATE_FILE", "cache/preroll-state.json"),
|
||||
"preroll_weekday": _coerce_value("preroll_weekday", _env("PREROLL_WEEKDAY", "ROTATE_WEEKDAY", "0")),
|
||||
"preroll_time": _env("PREROLL_TIME", "SCHEDULE_TIME", "02:00"),
|
||||
"emby_tasks": emby_tasks_service.default_settings(),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_payload(data: dict) -> dict:
|
||||
return {k: _coerce_value(k, v) for k, v in data.items() if k in FIELD_SPECS and v is not None}
|
||||
|
||||
|
||||
def _read_file() -> dict:
|
||||
if not SETTINGS_FILE.exists():
|
||||
return {}
|
||||
@@ -42,22 +146,90 @@ def _read_file() -> dict:
|
||||
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return {k: str(v) for k, v in data.items() if k in FIELDS and v is not None}
|
||||
return _normalize_payload(data if isinstance(data, dict) else {})
|
||||
|
||||
|
||||
def _write_file(values: dict) -> None:
|
||||
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
SETTINGS_FILE.write_text(json.dumps(values, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _ensure_postgres_schema() -> None:
|
||||
with postgres_connect() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(SCHEMA)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _read_postgres() -> dict:
|
||||
with postgres_connect() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT key, value_json FROM app_settings")
|
||||
rows = cur.fetchall()
|
||||
data: dict[str, object] = {}
|
||||
for row in rows:
|
||||
data[row["key"]] = row["value_json"]
|
||||
return _normalize_payload(data)
|
||||
|
||||
|
||||
def _write_postgres(values: dict) -> None:
|
||||
payload = _normalize_payload(values)
|
||||
with postgres_connect() as conn:
|
||||
with conn.cursor() as cur:
|
||||
for key, value in payload.items():
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO app_settings (key, value_json, updated_at)
|
||||
VALUES (%s, %s::jsonb, NOW())
|
||||
ON CONFLICT (key)
|
||||
DO UPDATE SET value_json = EXCLUDED.value_json, updated_at = NOW()
|
||||
""",
|
||||
(key, json.dumps(value)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _migrate_file_to_postgres() -> None:
|
||||
file_values = _read_file()
|
||||
if not file_values:
|
||||
return
|
||||
current_db = _read_postgres()
|
||||
merged = dict(current_db)
|
||||
for key, value in file_values.items():
|
||||
if key not in merged or merged[key] in ("", {}, 0, False):
|
||||
merged[key] = value
|
||||
_write_postgres(merged)
|
||||
|
||||
|
||||
def init_store() -> None:
|
||||
if using_postgres():
|
||||
_ensure_postgres_schema()
|
||||
_migrate_file_to_postgres()
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
"""Effective settings: env defaults overlaid with the saved file."""
|
||||
"""Effective settings: env defaults overlaid with persisted overrides."""
|
||||
values = env_defaults()
|
||||
values.update(_read_file())
|
||||
if using_postgres():
|
||||
values.update(_read_postgres())
|
||||
else:
|
||||
values.update(_read_file())
|
||||
return values
|
||||
|
||||
|
||||
def save(updates: dict) -> dict:
|
||||
"""Persist the editable subset of ``updates`` and return effective settings."""
|
||||
current = _read_file()
|
||||
for key in FIELDS:
|
||||
current = _read_postgres() if using_postgres() else _read_file()
|
||||
for key in FIELD_SPECS:
|
||||
if key in updates and updates[key] is not None:
|
||||
current[key] = str(updates[key]).strip()
|
||||
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
SETTINGS_FILE.write_text(json.dumps(current, indent=2), encoding="utf-8")
|
||||
value = updates[key]
|
||||
if FIELD_SPECS[key] == "string":
|
||||
current[key] = str(value).strip()
|
||||
else:
|
||||
current[key] = _coerce_value(key, value)
|
||||
if using_postgres():
|
||||
_ensure_postgres_schema()
|
||||
_write_postgres(current)
|
||||
else:
|
||||
_write_file(current)
|
||||
return load()
|
||||
|
||||
Reference in New Issue
Block a user