Homelabtoolkit v2
This commit is contained in:
+108
-1
@@ -14,12 +14,15 @@ Both are synchronous (filesystem + blocking HTTP); call them from FastAPI via
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from typing import Callable, Iterator
|
||||
|
||||
import requests
|
||||
|
||||
@@ -27,6 +30,10 @@ try: # Optional: only needed for the "find missing year/cover" online lookups.
|
||||
import musicbrainzngs
|
||||
|
||||
musicbrainzngs.set_useragent("HomelabToolkit", "1.0", "homelab-toolkit@example.com")
|
||||
# musicbrainzngs logs "uncaught attribute"/"uncaught tag" at INFO whenever the
|
||||
# MusicBrainz XML carries fields it doesn't model (e.g. release-group type-id).
|
||||
# Harmless noise — keep only real warnings.
|
||||
logging.getLogger("musicbrainzngs").setLevel(logging.WARNING)
|
||||
_HAS_MUSICBRAINZ = True
|
||||
except Exception: # pragma: no cover - optional dependency
|
||||
_HAS_MUSICBRAINZ = False
|
||||
@@ -46,6 +53,9 @@ YEAR_ALBUM_FOLDER_RE = re.compile(r"^\s*(\d{4})\s*-\s*(.+?)\s*$")
|
||||
LogCallback = Callable[[dict], None]
|
||||
|
||||
|
||||
DEFAULT_RECENT_WINDOW_SECONDS = 2 * 60 * 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessOptions:
|
||||
folder_cleanup: bool = False
|
||||
@@ -54,9 +64,15 @@ class ProcessOptions:
|
||||
lyrics: bool = False
|
||||
covers: bool = True
|
||||
dry_run: bool = True
|
||||
recent_only: bool = False
|
||||
recent_window_seconds: int = DEFAULT_RECENT_WINDOW_SECONDS
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ProcessOptions":
|
||||
try:
|
||||
window = int(data.get("recent_window_seconds") or DEFAULT_RECENT_WINDOW_SECONDS)
|
||||
except (TypeError, ValueError):
|
||||
window = DEFAULT_RECENT_WINDOW_SECONDS
|
||||
return cls(
|
||||
folder_cleanup=bool(data.get("folder_cleanup", False)),
|
||||
rename=bool(data.get("rename", False)),
|
||||
@@ -64,6 +80,8 @@ class ProcessOptions:
|
||||
lyrics=bool(data.get("lyrics", False)),
|
||||
covers=bool(data.get("covers", True)),
|
||||
dry_run=bool(data.get("dry_run", True)),
|
||||
recent_only=bool(data.get("recent_only", False)),
|
||||
recent_window_seconds=window,
|
||||
)
|
||||
|
||||
|
||||
@@ -246,6 +264,16 @@ def analyze_album(album_folder: Path) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _was_created_recently(folder: Path, window_seconds: int) -> bool:
|
||||
"""True if the folder was created/modified within the window (recent-only mode)."""
|
||||
try:
|
||||
stat = folder.stat()
|
||||
except OSError:
|
||||
return False
|
||||
age = time.time() - max(stat.st_ctime, stat.st_mtime)
|
||||
return 0 <= age <= window_seconds
|
||||
|
||||
|
||||
def _iter_album_folders(root: Path):
|
||||
for first_level in root.iterdir():
|
||||
if not first_level.is_dir():
|
||||
@@ -513,6 +541,16 @@ def process_library(
|
||||
else:
|
||||
folders = list(_iter_album_folders(root))
|
||||
|
||||
if options.recent_only and not album_paths:
|
||||
before = len(folders)
|
||||
folders = [f for f in folders if _was_created_recently(f, options.recent_window_seconds)]
|
||||
rec.emit(
|
||||
"info",
|
||||
"recent",
|
||||
f"Recent-only: {len(folders)} of {before} albums modified in the last "
|
||||
f"{options.recent_window_seconds // 3600}h",
|
||||
)
|
||||
|
||||
rec.emit(
|
||||
"info",
|
||||
"start",
|
||||
@@ -531,3 +569,72 @@ def process_library(
|
||||
"actions": rec.actions,
|
||||
"counts": rec.counts,
|
||||
}
|
||||
|
||||
|
||||
# ── streaming variants (disk-efficient; yield as work happens) ────────────────
|
||||
|
||||
|
||||
def scan_library_stream(root: Path | None = None) -> Iterator[dict]:
|
||||
"""Yield one album analysis at a time so the UI can render incrementally.
|
||||
|
||||
Memory stays flat (no full list is accumulated) and the slow NAS walk
|
||||
streams results to the caller as each album folder is inspected.
|
||||
"""
|
||||
root = root or MUSIC_ROOT
|
||||
if not root.exists():
|
||||
yield {"type": "error", "message": f"Music root not found: {root}"}
|
||||
return
|
||||
|
||||
yield {"type": "start", "root": str(root)}
|
||||
album_count = missing_cover = needs_rename = extra_files = 0
|
||||
for album_folder in _iter_album_folders(root):
|
||||
try:
|
||||
album = analyze_album(album_folder)
|
||||
except OSError:
|
||||
continue
|
||||
album_count += 1
|
||||
if not album["has_cover"]:
|
||||
missing_cover += 1
|
||||
if album["needs_folder_rename"]:
|
||||
needs_rename += 1
|
||||
extra_files += album["extra_file_count"]
|
||||
yield {"type": "album", "album": album, "scanned": album_count}
|
||||
|
||||
yield {
|
||||
"type": "summary",
|
||||
"root": str(root),
|
||||
"album_count": album_count,
|
||||
"missing_cover_count": missing_cover,
|
||||
"needs_rename_count": needs_rename,
|
||||
"extra_file_count": extra_files,
|
||||
}
|
||||
|
||||
|
||||
def process_library_stream(
|
||||
options: ProcessOptions,
|
||||
*,
|
||||
root: Path | None = None,
|
||||
album_paths: list[str] | None = None,
|
||||
) -> Iterator[dict]:
|
||||
"""Run maintenance and yield each action the moment it happens.
|
||||
|
||||
``process_library`` already reports through a ``log`` callback; we bridge that
|
||||
to a queue drained by this generator so the HTTP response streams live.
|
||||
"""
|
||||
events: queue.Queue = queue.Queue()
|
||||
sentinel = object()
|
||||
|
||||
def worker():
|
||||
try:
|
||||
process_library(options, root=root, log=events.put, album_paths=album_paths)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
events.put({"level": "warn", "action": "error", "message": str(exc)})
|
||||
finally:
|
||||
events.put(sentinel)
|
||||
|
||||
threading.Thread(target=worker, name="music_process_stream", daemon=True).start()
|
||||
while True:
|
||||
item = events.get()
|
||||
if item is sentinel:
|
||||
break
|
||||
yield item
|
||||
|
||||
Reference in New Issue
Block a user