Homelabtoolkit v2

This commit is contained in:
2026-06-08 21:58:16 +12:00
parent 040fbacc70
commit 3c77066beb
75 changed files with 16945 additions and 374 deletions
+159
View File
@@ -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,
}
+93
View File
@@ -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
+7
View File
@@ -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);
+18
View File
@@ -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:
+615
View File
@@ -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])
+634
View File
@@ -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
View File
@@ -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
+628
View File
@@ -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
+157
View File
@@ -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],
}
+440
View File
@@ -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
View File
@@ -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()