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
+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