Improve library identification and track inspection
This commit is contained in:
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
@@ -9,10 +11,10 @@ from urllib.parse import parse_qs, unquote
|
||||
|
||||
from .config import load_config, public_config
|
||||
from .downloads import downloads_snapshot
|
||||
from .library import library_snapshot, normalize_library
|
||||
from .library import enrich_library_metadata, library_snapshot, normalize_library
|
||||
from .logging_setup import configure_logging
|
||||
from .media_probe import edit_track, media_probe
|
||||
from .metadata import test_tmdb
|
||||
from .metadata import movie_metadata_by_id, search_metadata, series_metadata_by_id, test_tmdb
|
||||
from .organizer import execute_bundle_plan
|
||||
from .releases import fetch_releases
|
||||
from .scanner import Scanner
|
||||
@@ -155,6 +157,73 @@ configure_logging(CONFIG["paths"]["logs"], CONFIG["app"].get("log_level", "INFO"
|
||||
STORE = JsonStore(CONFIG["paths"]["data"])
|
||||
apply_settings(CONFIG, STORE.snapshot().get("settings", {}))
|
||||
SCANNER = Scanner(CONFIG, STORE)
|
||||
METADATA_REFRESH = {"running": False, "started_at": None, "finished_at": None, "error": None}
|
||||
METADATA_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def start_metadata_refresh() -> bool:
|
||||
with METADATA_LOCK:
|
||||
if METADATA_REFRESH["running"]:
|
||||
return False
|
||||
METADATA_REFRESH.update({"running": True, "started_at": time.time(), "finished_at": None, "error": None})
|
||||
|
||||
def worker() -> None:
|
||||
try:
|
||||
snap = STORE.snapshot()
|
||||
library = snap.get("library") or {}
|
||||
if not library.get("items"):
|
||||
library = library_snapshot(CONFIG, snap.get("library"))
|
||||
enriched = enrich_library_metadata(CONFIG, library)
|
||||
STORE.set_library(enriched)
|
||||
with METADATA_LOCK:
|
||||
METADATA_REFRESH.update({"running": False, "finished_at": time.time(), "error": None})
|
||||
except Exception as exc:
|
||||
with METADATA_LOCK:
|
||||
METADATA_REFRESH.update({"running": False, "finished_at": time.time(), "error": str(exc)})
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
return True
|
||||
|
||||
|
||||
def public_library_payload(library: dict) -> dict:
|
||||
public = normalize_library(library)
|
||||
public.pop("items", None)
|
||||
return public
|
||||
|
||||
|
||||
def find_collection(library: dict, key: str, media_library: str) -> dict | None:
|
||||
collections = library.get("collections") or {}
|
||||
group = "series" if media_library == "tv" else "movies"
|
||||
return next((item for item in collections.get(group, []) if item.get("key") == key), None)
|
||||
|
||||
|
||||
def identify_collection(payload: dict) -> dict:
|
||||
media_library = "tv" if payload.get("library") == "tv" else "movie"
|
||||
key = str(payload.get("key") or "")
|
||||
tmdb_id = int(payload.get("tmdb_id") or 0)
|
||||
snap = STORE.snapshot()
|
||||
library = snap.get("library") or {}
|
||||
collection = find_collection(library, key, media_library)
|
||||
if not collection:
|
||||
raise ValueError("library item was not found")
|
||||
if media_library == "tv":
|
||||
seasons = {int(season.get("season") or 0) for season in collection.get("seasons", []) if int(season.get("season") or 0) > 0}
|
||||
metadata = series_metadata_by_id(CONFIG, tmdb_id, seasons)
|
||||
else:
|
||||
metadata = movie_metadata_by_id(CONFIG, tmdb_id)
|
||||
if metadata.get("release_date"):
|
||||
collection["year"] = int(metadata["release_date"][:4])
|
||||
collection["metadata"] = metadata
|
||||
collection["title"] = metadata.get("title") or collection.get("title")
|
||||
identifications = library.setdefault("identifications", {})
|
||||
identifications[key] = {
|
||||
"library": media_library,
|
||||
"tmdb_id": tmdb_id,
|
||||
"metadata": metadata,
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
STORE.set_library(library)
|
||||
return collection
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
@@ -222,9 +291,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"truncated": False,
|
||||
"cached": False,
|
||||
}
|
||||
library = normalize_library(library)
|
||||
library.pop("items", None)
|
||||
self.send_json({"library": library})
|
||||
self.send_json({"library": public_library_payload(library)})
|
||||
elif path == "/api/library/metadata/status":
|
||||
self.send_json({"metadata": METADATA_REFRESH})
|
||||
elif path == "/api/downloads":
|
||||
self.send_json({"downloads": downloads_snapshot(CONFIG, STORE.snapshot())})
|
||||
elif path == "/api/releases":
|
||||
@@ -294,9 +363,25 @@ class Handler(BaseHTTPRequestHandler):
|
||||
STORE.set_organizer_queue(updated)
|
||||
self.send_json({"ok": True})
|
||||
elif path == "/api/library/scan":
|
||||
library = library_snapshot(CONFIG)
|
||||
snap = STORE.snapshot()
|
||||
library = library_snapshot(CONFIG, snap.get("library"))
|
||||
STORE.set_library(library)
|
||||
self.send_json({"library": library})
|
||||
elif path == "/api/library/metadata/refresh":
|
||||
started = start_metadata_refresh()
|
||||
self.send_json({"started": started, "metadata": METADATA_REFRESH}, HTTPStatus.ACCEPTED)
|
||||
elif path == "/api/library/identify/search":
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
body = self.rfile.read(length).decode() if length else "{}"
|
||||
payload = json.loads(body)
|
||||
results = search_metadata(CONFIG, "tv" if payload.get("library") == "tv" else "movie", str(payload.get("query") or ""), payload.get("year"))
|
||||
self.send_json({"results": results})
|
||||
elif path == "/api/library/identify/apply":
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
body = self.rfile.read(length).decode() if length else "{}"
|
||||
payload = json.loads(body)
|
||||
collection = identify_collection(payload)
|
||||
self.send_json({"collection": collection})
|
||||
elif path == "/api/tools/transcoder/run-next":
|
||||
result = run_next_transcode(CONFIG, STORE.snapshot().get("library"))
|
||||
STORE.add_event("info", f"transcoder: {result.get('status')}")
|
||||
|
||||
@@ -266,7 +266,28 @@ def build_collections(config: dict, items: list[dict], enrich: bool = False) ->
|
||||
}
|
||||
|
||||
|
||||
def library_snapshot(config: dict) -> dict:
|
||||
def preserve_metadata(collections: dict, previous_library: dict | None) -> dict:
|
||||
previous = (previous_library or {}).get("collections") or {}
|
||||
previous_by_key = {
|
||||
item.get("key"): item
|
||||
for group in ("movies", "series")
|
||||
for item in previous.get(group, [])
|
||||
if item.get("key")
|
||||
}
|
||||
for group in ("movies", "series"):
|
||||
for item in collections.get(group, []):
|
||||
old = previous_by_key.get(item.get("key"))
|
||||
old_meta = (old or {}).get("metadata") or {}
|
||||
if old_meta.get("source") == "tmdb":
|
||||
item["metadata"] = old_meta
|
||||
if old_meta.get("manual"):
|
||||
item["title"] = old_meta.get("title") or item.get("title")
|
||||
if item.get("library") == "movie" and old_meta.get("release_date"):
|
||||
item["year"] = int(old_meta["release_date"][:4])
|
||||
return collections
|
||||
|
||||
|
||||
def library_snapshot(config: dict, previous_library: dict | None = None) -> dict:
|
||||
items = []
|
||||
extensions = Counter()
|
||||
ignored_dirs = {"$RECYCLE.BIN", "System Volume Information", ".Trash-1000"}
|
||||
@@ -320,12 +341,27 @@ def library_snapshot(config: dict) -> dict:
|
||||
})
|
||||
enrich_limit = int(app.get("library_metadata_enrich_max_items", 500))
|
||||
should_enrich = bool(config.get("metadata", {}).get("tmdb_enabled", True)) and len(items) <= enrich_limit
|
||||
collections = build_collections(config, items, enrich=should_enrich)
|
||||
if not should_enrich:
|
||||
collections = preserve_metadata(collections, previous_library)
|
||||
return normalize_library({
|
||||
"drives": drive_stats(config),
|
||||
"items": sorted(items, key=lambda item: item["modified"], reverse=True),
|
||||
"collections": build_collections(config, items, enrich=should_enrich),
|
||||
"collections": collections,
|
||||
"extensions": dict(extensions.most_common()),
|
||||
"scanned_files": scanned,
|
||||
"truncated": truncated,
|
||||
"metadata_enriched": should_enrich,
|
||||
"identifications": (previous_library or {}).get("identifications", {}),
|
||||
})
|
||||
|
||||
|
||||
def enrich_library_metadata(config: dict, library: dict) -> dict:
|
||||
items = library.get("items") or []
|
||||
enriched = {
|
||||
**library,
|
||||
"collections": build_collections(config, items, enrich=True),
|
||||
"metadata_enriched": True,
|
||||
"metadata_refreshed_at": time.time(),
|
||||
}
|
||||
return normalize_library(enriched)
|
||||
|
||||
@@ -117,6 +117,88 @@ def movie_metadata(config: dict, title: str, year: int | None = None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def search_metadata(config: dict, library: str, query: str, year: int | None = None) -> list[dict]:
|
||||
media_type = "tv" if library == "tv" else "movie"
|
||||
if not tmdb_available(config) or not query:
|
||||
return []
|
||||
params = {"query": query}
|
||||
if year and media_type == "movie":
|
||||
params["year"] = year
|
||||
elif year:
|
||||
params["first_air_date_year"] = year
|
||||
payload = tmdb_get(config, f"/search/{media_type}", params)
|
||||
results = payload.get("results") or []
|
||||
normalized = []
|
||||
for item in results[:12]:
|
||||
title = item.get("name") if media_type == "tv" else item.get("title")
|
||||
date = item.get("first_air_date") if media_type == "tv" else item.get("release_date")
|
||||
normalized.append({
|
||||
"tmdb_id": item.get("id"),
|
||||
"library": library,
|
||||
"title": title,
|
||||
"date": date,
|
||||
"year": int(date[:4]) if date and date[:4].isdigit() else None,
|
||||
"overview": item.get("overview") or "",
|
||||
"poster": poster_url(config, item.get("poster_path")),
|
||||
"backdrop": poster_url(config, item.get("backdrop_path")),
|
||||
"vote_average": item.get("vote_average"),
|
||||
})
|
||||
return normalized
|
||||
|
||||
|
||||
def movie_metadata_by_id(config: dict, tmdb_id: int) -> dict:
|
||||
payload = tmdb_get(config, f"/movie/{tmdb_id}")
|
||||
return {
|
||||
"source": "tmdb",
|
||||
"manual": True,
|
||||
"tmdb_id": payload.get("id"),
|
||||
"title": payload.get("title") or payload.get("original_title") or "",
|
||||
"overview": payload.get("overview") or "",
|
||||
"poster": poster_url(config, payload.get("poster_path")),
|
||||
"backdrop": poster_url(config, payload.get("backdrop_path")),
|
||||
"release_date": payload.get("release_date"),
|
||||
"vote_average": payload.get("vote_average"),
|
||||
}
|
||||
|
||||
|
||||
def series_metadata_by_id(config: dict, tmdb_id: int, seasons: set[int]) -> dict:
|
||||
payload = tmdb_get(config, f"/tv/{tmdb_id}")
|
||||
metadata = {
|
||||
"source": "tmdb",
|
||||
"manual": True,
|
||||
"tmdb_id": payload.get("id"),
|
||||
"title": payload.get("name") or payload.get("original_name") or "",
|
||||
"overview": payload.get("overview") or "",
|
||||
"poster": poster_url(config, payload.get("poster_path")),
|
||||
"backdrop": poster_url(config, payload.get("backdrop_path")),
|
||||
"first_air_date": payload.get("first_air_date"),
|
||||
"vote_average": payload.get("vote_average"),
|
||||
"seasons": {},
|
||||
}
|
||||
for season in sorted(seasons):
|
||||
try:
|
||||
season_payload = tmdb_get(config, f"/tv/{tmdb_id}/season/{season}")
|
||||
except Exception:
|
||||
continue
|
||||
metadata["seasons"][str(season)] = {
|
||||
"name": season_payload.get("name"),
|
||||
"air_date": season_payload.get("air_date"),
|
||||
"episode_count": len(season_payload.get("episodes") or []),
|
||||
"episodes": [
|
||||
{
|
||||
"season": season,
|
||||
"episode": episode.get("episode_number"),
|
||||
"title": episode.get("name"),
|
||||
"overview": episode.get("overview") or "",
|
||||
"air_date": episode.get("air_date"),
|
||||
"still": poster_url(config, episode.get("still_path")),
|
||||
}
|
||||
for episode in season_payload.get("episodes") or []
|
||||
],
|
||||
}
|
||||
return metadata
|
||||
|
||||
|
||||
def series_metadata(config: dict, title: str, seasons: set[int]) -> dict:
|
||||
result = first_result(config, "tv", title)
|
||||
if not result:
|
||||
|
||||
Reference in New Issue
Block a user