video: promote OMDb to a full 3rd enrichment worker (parity with TMDB/TVDB)
OMDb now has the same setup as TMDB/TVDB: a yellow dashboard orb (★ glyph) that spins/idles in the worker-orb animation, an entry in Manage Workers (Ratings coverage cards, pause/resume, retry, search), and a BACKGROUND ratings pass. - Worker 'ratings mode' (is_ratings): instead of a match queue it pulls ratings_next() (library items with an imdb_id and ratings_synced=0), fetches IMDb/RT/Metacritic, applies + marks synced. So the whole library gets ratings, not just titles you open (schema v7: ratings_synced). - enrichment_breakdown/unmatched/retry get an 'omdb' branch (coverage = ratings-filled, not matched). build_clients includes omdb; the lazy on-view backfill uses the omdb worker's client. - Dashboard orb + Manage Workers entry (★ glyph fallback where there's no logo), yellow accent. Seam tests: omdb worker rates the queue (ratings mode), ratings breakdown.
This commit is contained in:
parent
f06728b0a7
commit
b50f7c12f4
14 changed files with 230 additions and 112 deletions
|
|
@ -104,15 +104,11 @@ def register_routes(bp):
|
|||
|
||||
@bp.route("/enrichment/<service>/test", methods=["POST"])
|
||||
def video_enrichment_test(service):
|
||||
# OMDb is a ratings provider, not a worker.
|
||||
client = engine().ratings_client if service == "omdb" else None
|
||||
if client is None:
|
||||
w = engine().worker(service)
|
||||
client = w.client if w else None
|
||||
if client is None:
|
||||
w = engine().worker(service)
|
||||
if not w:
|
||||
return jsonify({"success": False, "error": "unknown service"}), 404
|
||||
try:
|
||||
ok, msg = client.test()
|
||||
ok, msg = w.client.test()
|
||||
return jsonify({"success": bool(ok), "message": msg, "error": None if ok else msg})
|
||||
except Exception:
|
||||
logger.exception("video enrichment test failed for %s", service)
|
||||
|
|
|
|||
|
|
@ -377,8 +377,10 @@ class OMDBClient:
|
|||
|
||||
|
||||
def build_clients(db) -> dict:
|
||||
"""Construct the source clients from the saved API keys (in video_settings)."""
|
||||
"""Construct the source clients from the saved API keys (in video_settings).
|
||||
OMDb is included as a worker (a ratings filler) alongside the matchers."""
|
||||
return {
|
||||
"tmdb": TMDBClient(db.get_setting("tmdb_api_key")),
|
||||
"tvdb": TVDBClient(db.get_setting("tvdb_api_key")),
|
||||
"omdb": OMDBClient(db.get_setting("omdb_api_key")),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from .worker import VideoEnrichmentWorker
|
|||
|
||||
logger = get_logger("video_enrichment.engine")
|
||||
|
||||
_DISPLAY = {"tmdb": "TMDB", "tvdb": "TVDB"}
|
||||
_DISPLAY = {"tmdb": "TMDB", "tvdb": "TVDB", "omdb": "OMDb"}
|
||||
|
||||
|
||||
class VideoEnrichmentEngine:
|
||||
|
|
@ -33,7 +33,10 @@ class VideoEnrichmentEngine:
|
|||
w.restore_paused()
|
||||
|
||||
def _backfill_ratings(self, kind, item_id):
|
||||
rc = self.ratings_client
|
||||
# The OMDb worker owns the ratings client (fallback to an injected one
|
||||
# for tests that don't build a worker).
|
||||
w = self.workers.get("omdb")
|
||||
rc = w.client if w else self.ratings_client
|
||||
if not rc or not getattr(rc, "enabled", False):
|
||||
return
|
||||
info = (self.db.movie_match_info(item_id) if kind == "movie"
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ class VideoEnrichmentWorker:
|
|||
self.interval = interval
|
||||
self.retry_days = retry_days
|
||||
|
||||
# OMDb is a ratings filler, not a matcher — it fetches scores by imdb_id
|
||||
# instead of running a match queue.
|
||||
self.is_ratings = hasattr(client, "ratings") and not hasattr(client, "match")
|
||||
|
||||
self.running = False
|
||||
self.paused = False
|
||||
self.should_stop = False
|
||||
|
|
@ -98,6 +102,8 @@ class VideoEnrichmentWorker:
|
|||
|
||||
def process_one(self) -> bool:
|
||||
"""Process a single item. Returns True if one was processed."""
|
||||
if self.is_ratings:
|
||||
return self._process_ratings_one()
|
||||
priority = None
|
||||
try:
|
||||
priority = self.db.get_setting("enrichment_priority") or None
|
||||
|
|
@ -140,6 +146,28 @@ class VideoEnrichmentWorker:
|
|||
logger.info("No %s match for %s '%s'", self.display_name, item["kind"], item["title"])
|
||||
return True
|
||||
|
||||
def _process_ratings_one(self) -> bool:
|
||||
"""OMDb worker: fetch IMDb/RT/Metacritic for the next library item that has
|
||||
an imdb_id but no ratings yet."""
|
||||
item = self.db.ratings_next()
|
||||
if not item:
|
||||
return False
|
||||
self.current_item = {"type": item["kind"], "name": item["title"]}
|
||||
try:
|
||||
r = self.client.ratings(item["imdb_id"])
|
||||
if r:
|
||||
self.db.apply_ratings(item["kind"], item["id"], r) # marks synced
|
||||
self.stats["matched"] += 1
|
||||
logger.info("Rated %s '%s' -> IMDb %s", item["kind"], item["title"], item["imdb_id"])
|
||||
else:
|
||||
self.db.mark_ratings_synced(item["kind"], item["id"])
|
||||
self.stats["not_found"] += 1
|
||||
except Exception:
|
||||
logger.exception("OMDb ratings fetch failed for '%s'", item["title"])
|
||||
self.stats["errors"] += 1
|
||||
self.db.mark_ratings_synced(item["kind"], item["id"]) # move on (no loop)
|
||||
return True
|
||||
|
||||
def _sync_episodes_once(self) -> bool:
|
||||
"""Background episode-sync: pull the FULL season/episode list for one
|
||||
already-matched show that hasn't been synced, so library cards show real
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ logger = get_logger("video_database")
|
|||
|
||||
# Bump when video_schema.sql changes in a way worth recording. Stored in
|
||||
# PRAGMA user_version as a backstop indicator (nothing gates on it yet).
|
||||
SCHEMA_VERSION = 6
|
||||
SCHEMA_VERSION = 7
|
||||
|
||||
_DEFAULT_DB_PATH = "database/video_library.db"
|
||||
_SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql"
|
||||
|
|
@ -95,6 +95,8 @@ _COLUMN_MIGRATIONS = [
|
|||
("movies", "metacritic", "INTEGER"),
|
||||
("shows", "imdb_rating", "REAL"), ("shows", "rt_rating", "INTEGER"),
|
||||
("shows", "metacritic", "INTEGER"),
|
||||
("movies", "ratings_synced", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("shows", "ratings_synced", "INTEGER NOT NULL DEFAULT 0"),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -287,6 +289,8 @@ class VideoDatabase:
|
|||
conn.close()
|
||||
|
||||
def enrichment_breakdown(self, service: str) -> dict:
|
||||
if service == "omdb":
|
||||
return self._ratings_breakdown()
|
||||
kinds = _ENRICH.get(service, {})
|
||||
out = {}
|
||||
conn = self._get_connection()
|
||||
|
|
@ -333,16 +337,16 @@ class VideoDatabase:
|
|||
conn.close()
|
||||
|
||||
def apply_ratings(self, kind: str, item_id: int, ratings: dict) -> None:
|
||||
"""Store IMDb / RT / Metacritic scores (from OMDb). Ratings are dynamic, so
|
||||
these overwrite (unlike the gap-only metadata backfill)."""
|
||||
"""Store IMDb / RT / Metacritic scores (from OMDb) + mark ratings_synced.
|
||||
Ratings are dynamic, so these overwrite (unlike gap-only metadata)."""
|
||||
table = {"movie": "movies", "show": "shows"}.get(kind)
|
||||
cols = {"imdb_rating", "rt_rating", "metacritic"}
|
||||
sets, params = [], []
|
||||
sets, params = ["ratings_synced=1"], []
|
||||
for c, v in (ratings or {}).items():
|
||||
if c in cols and v is not None:
|
||||
sets.append(f"{c}=?")
|
||||
params.append(v)
|
||||
if not table or not sets:
|
||||
if not table:
|
||||
return
|
||||
params.append(item_id)
|
||||
conn = self._get_connection()
|
||||
|
|
@ -352,6 +356,33 @@ class VideoDatabase:
|
|||
finally:
|
||||
conn.close()
|
||||
|
||||
def ratings_next(self) -> dict | None:
|
||||
"""Next library item that needs OMDb ratings (has an imdb_id, not synced).
|
||||
Drives the OMDb worker's background pass. Returns {kind, id, title, imdb_id}."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
for kind, tbl in (("movie", "movies"), ("show", "shows")):
|
||||
row = conn.execute(
|
||||
f"SELECT id, title, imdb_id FROM {tbl} "
|
||||
"WHERE imdb_id IS NOT NULL AND imdb_id<>'' AND ratings_synced=0 "
|
||||
"ORDER BY id LIMIT 1").fetchone()
|
||||
if row:
|
||||
return {"kind": kind, "id": row["id"], "title": row["title"], "imdb_id": row["imdb_id"]}
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def mark_ratings_synced(self, kind: str, item_id: int) -> None:
|
||||
table = {"movie": "movies", "show": "shows"}.get(kind)
|
||||
if not table:
|
||||
return
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
conn.execute(f"UPDATE {table} SET ratings_synced=1 WHERE id=?", (item_id,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def mark_episodes_synced(self, show_id: int) -> None:
|
||||
"""Flag that the show's FULL episode list has been pulled from metadata
|
||||
(so the lazy on-view refresh doesn't re-cascade every visit)."""
|
||||
|
|
@ -383,6 +414,25 @@ class VideoDatabase:
|
|||
finally:
|
||||
conn.close()
|
||||
|
||||
def _ratings_breakdown(self) -> dict:
|
||||
"""OMDb 'coverage' breakdown: matched = ratings present, pending = has an
|
||||
imdb_id but not fetched, not_found = fetched but OMDb had no rating."""
|
||||
conn = self._get_connection()
|
||||
out = {}
|
||||
try:
|
||||
for kind, tbl in (("movie", "movies"), ("show", "shows")):
|
||||
def c(where):
|
||||
return conn.execute(f"SELECT COUNT(*) FROM {tbl} WHERE {where}").fetchone()[0]
|
||||
out[kind] = {
|
||||
"matched": c("imdb_rating IS NOT NULL"),
|
||||
"not_found": c("ratings_synced=1 AND imdb_rating IS NULL AND imdb_id IS NOT NULL"),
|
||||
"errors": 0,
|
||||
"pending": c("imdb_id IS NOT NULL AND imdb_id<>'' AND ratings_synced=0"),
|
||||
}
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def show_season_numbers(self, show_id: int) -> list:
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
|
|
@ -446,6 +496,8 @@ class VideoDatabase:
|
|||
search=None, limit: int = 50, offset: int = 0) -> dict:
|
||||
if kind == "episode" and service == "tmdb":
|
||||
return self._episodes_missing_art(search, limit, offset)
|
||||
if service == "omdb" and kind in ("movie", "show"):
|
||||
return self._ratings_unmatched(kind, search, limit, offset)
|
||||
spec = _ENRICH.get(service, {}).get(kind)
|
||||
if not spec:
|
||||
return {"items": [], "total": 0}
|
||||
|
|
@ -503,8 +555,41 @@ class VideoDatabase:
|
|||
finally:
|
||||
conn.close()
|
||||
|
||||
def _ratings_unmatched(self, kind: str, search, limit: int, offset: int) -> dict:
|
||||
tbl = {"movie": "movies", "show": "shows"}[kind]
|
||||
where = ["imdb_rating IS NULL", "imdb_id IS NOT NULL", "imdb_id<>''"]
|
||||
params: list = []
|
||||
if search:
|
||||
where.append("title LIKE ? COLLATE NOCASE")
|
||||
params.append("%" + search + "%")
|
||||
where_sql = " WHERE " + " AND ".join(where)
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
total = conn.execute(f"SELECT COUNT(*) FROM {tbl}{where_sql}", params).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
f"SELECT id, title, year, (poster_url IS NOT NULL AND poster_url<>'') AS has_poster "
|
||||
f"FROM {tbl}{where_sql} ORDER BY COALESCE(sort_title, title) COLLATE NOCASE "
|
||||
"LIMIT ? OFFSET ?", params + [limit, offset]).fetchall()
|
||||
return {"items": [dict(r) | {"has_poster": bool(r["has_poster"])} for r in rows], "total": total}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def enrichment_retry(self, service: str, kind: str, scope: str = "failed", item_id=None) -> int:
|
||||
"""Re-queue items by resetting status/last_attempted to NULL."""
|
||||
if service == "omdb":
|
||||
tbl = {"movie": "movies", "show": "shows"}.get(kind)
|
||||
if not tbl:
|
||||
return 0
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
if scope == "item" and item_id is not None:
|
||||
cur = conn.execute(f"UPDATE {tbl} SET ratings_synced=0 WHERE id=?", (item_id,))
|
||||
else:
|
||||
cur = conn.execute(f"UPDATE {tbl} SET ratings_synced=0 WHERE imdb_rating IS NULL")
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
finally:
|
||||
conn.close()
|
||||
spec = _ENRICH.get(service, {}).get(kind)
|
||||
if not spec:
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ CREATE TABLE IF NOT EXISTS movies (
|
|||
imdb_rating REAL, -- IMDb (0-10, via OMDb)
|
||||
rt_rating INTEGER, -- Rotten Tomatoes (0-100)
|
||||
metacritic INTEGER, -- Metacritic (0-100)
|
||||
ratings_synced INTEGER NOT NULL DEFAULT 0, -- OMDb ratings fetched?
|
||||
poster_url TEXT,
|
||||
backdrop_url TEXT,
|
||||
logo_url TEXT, -- transparent title logo (clearlogo)
|
||||
|
|
@ -126,6 +127,7 @@ CREATE TABLE IF NOT EXISTS shows (
|
|||
imdb_rating REAL, -- IMDb (0-10, via OMDb)
|
||||
rt_rating INTEGER, -- Rotten Tomatoes (0-100)
|
||||
metacritic INTEGER, -- Metacritic (0-100)
|
||||
ratings_synced INTEGER NOT NULL DEFAULT 0, -- OMDb ratings fetched?
|
||||
first_air_date TEXT,
|
||||
last_air_date TEXT,
|
||||
poster_url TEXT,
|
||||
|
|
|
|||
|
|
@ -1,116 +1,58 @@
|
|||
# SoulSync 2.6.4 — Merge `dev` → `main`
|
||||
# 2.7.2
|
||||
|
||||
Patch release on top of 2.6.3. Headline:
|
||||
a big feature + fix pass on top of 2.7.1 — playlist-folder mirroring, a redesigned quality-upgrade finder, smarter artist enrichment, better tidal/youtube/soundcloud imports, M3U export, and a stack of issue fixes.
|
||||
|
||||
- **#721 — Usenet album bundles stuck on "downloading release" when SAB History flips before storage lands.** Reported by @IamGroot60 against 2.6.3, validated on the `fix/usenet-bundle-save-path-handoff` branch, merged via PR #723.
|
||||
## organize playlists into folders
|
||||
|
||||
Everything from 2.6.3 also rolls in unchanged (was bumped on dev but never tagged / published to main / docker, so this is the first time these changes reach users).
|
||||
soulsync can now mirror each playlist into its own folder on disk, so external players (plex / jellyfin / music assistant) see them as real folders:
|
||||
|
||||
---
|
||||
- **symlink or copy** — symlink for no extra disk, copy if you want standalone files.
|
||||
- **self-maintaining** — rebuilds after every sync and prunes tracks you removed; separate output root + a manual rebuild button in settings.
|
||||
- album / single / playlist downloads all share the exact same post-processing, so the folder view always matches the library.
|
||||
|
||||
## 2.6.4 — the patch
|
||||
## quality upgrade finder (replaces the old quality scanner)
|
||||
|
||||
### #721 — Usenet album bundle stuck on "downloading release" when SAB History flips before `storage` lands
|
||||
Follow-up to the 2.6.3 queue→history handoff fix (#706). 2.6.3 covered the gap where SAB removes a job from the queue before adding it to history. **2.6.4** covers a second-stage gap: SAB flips `status` to `Completed` in History a few seconds **before** its post-processing writes the final `storage` field.
|
||||
the old Quality Scanner judged quality by file extension only, ignored the bitrate profile, and auto-dumped everything into the wishlist with no review. it's gone — replaced by a proper **Library Maintenance job** (off by default):
|
||||
|
||||
Pre-fix: `poll_album_download` saw the first `Completed` read with `save_path=None` and bailed. The bundle plugin marked the batch failed, but the UI froze on the last `downloading progress=0.61` emit because the terminal `failed` emit never registered (renderer holds the last-known progress).
|
||||
- **bitrate-aware** — judges each track by format *and* bitrate against your quality profile, so a 320 mp3 passes a flac+320+256 profile and a 128 mp3 doesn't (enabling mp3-320/256 finally counts).
|
||||
- **findings, not auto-action** — it scans (watchlist or whole library), finds below-quality tracks, and creates reviewable findings. nothing hits the wishlist until you Apply.
|
||||
- **exact matching** — resolves the better version by the most precise identity available: the source track-ID embedded in the file → ISRC → the album's tracklist (by stored album-id or album search) → name search. carries real album context, and the fuzzy steps reject wrong-length cuts (live/edit/remix).
|
||||
- skips tracks it already proposed, so re-runs are cheap. transcode/fake-lossless detection stays with the existing Fake Lossless Detector job.
|
||||
|
||||
- **`poll_album_download`**: separate transient counter for "completed but no save_path." Tolerates up to `transient_miss_threshold` (default 5) consecutive reads in that state — gives SAB ~10s to land the path. When it arrives, return normally. When it doesn't, fail loudly with an explicit error pointing at the missing field.
|
||||
- **Sticky save_path**: earlier `downloading` reads with a non-empty `save_path` (qBit / Transmission set this from the start) remain cached. So torrent flows aren't affected by the retry path.
|
||||
- **SAB adapter (`_parse_history_slot`)**: widened the save_path fallback chain — `storage` → `path` → `download_path` → `dirname`. Covers SAB version differences (older builds populated `path`) and forks that expose `download_path` or `dirname`. Whitespace-only values skipped. `incomplete_path` intentionally NOT in the chain — it'd bypass the retry window and point at the in-progress staging dir.
|
||||
- **Diagnostic**: loud debug log when none of the known fields land, dumping the slot keys so we can grow `_HISTORY_SAVE_PATH_KEYS` if a fork ships a novel field name.
|
||||
## smarter artist enrichment (#868)
|
||||
|
||||
**9 new tests**:
|
||||
- `test_album_bundle.py` (3): late-save_path arrival recovers; threshold-exhausted fails cleanly; sticky save_path keeps torrent flows working.
|
||||
- `test_usenet_client_adapters.py` (6): each fallback field tier, whitespace-only skip, all-empty returns None, `incomplete_path` ignored.
|
||||
enrichment matched artists by name only — so for a common name (there are ~5 "Rone"s) it grabbed whichever the source ranked first, often the wrong one, which then drove a wrong/sparse library discography. now when multiple same-name artists clear the name gate, it picks the one whose catalog **overlaps the albums you actually own**. wired into Spotify (+ no-auth), iTunes, Deezer, and MusicBrainz. "click to re-match" now actually re-resolves (it used to re-confirm the wrong id).
|
||||
|
||||
132 album-bundle + usenet tests pass. Strictly additive — zero impact on users whose SAB returns `storage` on the first Completed read.
|
||||
## tidal playlist discovery (#867)
|
||||
|
||||
---
|
||||
- discovery used to show only a subset of a playlist's tracks (a 59-track playlist surfaced ~21) — now it shows them all, driven by the authoritative backend results, and `get_playlist` chunks its track-ID fetch to Tidal's page cap so nothing's dropped.
|
||||
- the discovery modal opens **instantly** instead of hanging ~10s on a blocking pre-fetch, and it's no longer interactable while it's still loading.
|
||||
|
||||
## Everything else from 2.6.3 (carried forward)
|
||||
## export server playlists as M3U
|
||||
|
||||
### Fixes
|
||||
one-click **Export M3U** button in the Server Playlists compare/editor toolbar — writes a standard `.m3u` and downloads it to your browser (great for music assistant). resolved via one bulk db read so it doesn't hang under active enrichment/scan writes.
|
||||
|
||||
**#715 — Soulseek album downloads stuck on "failed" after slskd finished the release.**
|
||||
`core/soulseek_client._resolve_downloaded_album_file` probed 3 hard-coded candidate paths. On the common slskd config `directories.downloads.username = true`, files land at `<download_dir>/<username>/<filename>` — none of the 3 candidates carried a username segment, so every file looked locally missing and the bundle poll silently spun for ~30 minutes before marking the batch failed.
|
||||
## better youtube & soundcloud imports
|
||||
|
||||
- Lifted the per-track flow's recursive walk-by-basename helper into `core/downloads/file_finder.py` (`find_completed_audio_file`). Bundle resolver now delegates to it. Default-slskd users see zero behavior change (3-candidate fast path preserved).
|
||||
- Bundle poll detects "slskd reports Completed but local file can't be resolved past a 45s grace window" → exits early with explicit log line pointing at the likely `soulseek.download_path` mismatch.
|
||||
- Misleading `"(0 tracks, quality=)"` log on the preflight-reuse path fixed.
|
||||
- **17 new tests** pin every slskd layout (flat, username-prefixed, full-tree-preserved, deep nested, dedup-suffix, quarantine-skip, YouTube/Tidal encoded, transfer-dir fallback, fuzzy variants).
|
||||
- **#863** — youtube / youtube-music playlists that imported as "Unknown Artist" now recover the real artist from the track's music metadata, the "Artist - Title" pattern, or the uploading channel (recovery runs in the async discovery worker so the parse stays fast).
|
||||
- **#865** — paste a soundcloud track link, including unlisted / private share urls, into manual search to download it directly.
|
||||
|
||||
**Auto-Sync ListenBrainz pipelines stuck on `Refreshing:` for 5+ minutes.**
|
||||
Refresh path ran `_maybe_discover` inline AND Phase 2 ran the same matching engine via `run_playlist_discovery_worker`. LB tracks discovered twice; refresh-side run blocked with zero progress emission. Also: LB manager only exposed `update_all_playlists` (refreshing one playlist re-pulled all 12+ cached playlists). Also: LB adapter had a silent `except Exception: pass` masking real API failures.
|
||||
## watchlist & playlists
|
||||
|
||||
- Pipeline sets `skip_discovery=True` on refresh config; Phase 2 handles discovery with proper progress emits.
|
||||
- New `LBManager.refresh_playlist(mbid)` targeted refresh.
|
||||
- LB adapter logs exceptions with traceback at warning level + returns `None`.
|
||||
- **12 new tests**.
|
||||
- **follow-only watchlist** — per-artist "auto-download" toggle (on by default). off = scans still discover/surface new releases, they just don't auto-add to the wishlist.
|
||||
- **rename mirrored playlists** — a custom name that changes the display + sync name, survives upstream refreshes, and still tracks the same server playlist.
|
||||
|
||||
**Wishlist: harden Spotify backfill — poisoned `tn=1` can't mask a lean album.**
|
||||
Spotify-API backfill that hydrates `release_date` / `total_tracks` was coupled to the "track_number missing" branch, so a poisoned default-1 track_number short-circuited it. Lifted to `core/downloads/track_metadata_backfill.py` with split concerns — track-number resolution keeps its precedence chain; album hydration runs whenever `release_date` / `total_tracks` missing, independent of track_number. Single API call still serves both. Also `core/wishlist/routes.py:_build_track_data` no longer defaults `track_number=1` / `disc_number=1` / `total_tracks=1` / `release_date=''`. **24 new tests**.
|
||||
## more requested features
|
||||
|
||||
**Wishlist: fix three regressions causing all imports to land as track 01.**
|
||||
Track→dict conversion in payload helpers dropped everything except `album.name`; Deezer-sourced discovery matches saved without `track_number`/`disc_number`; import pipeline only consulted `album_info.track_number` before falling to the filename. Track_number resolution chain lifted into `core/imports/track_number.py:resolve_track_number` with 18 unit tests.
|
||||
- **export your roster** — one button + scope selector dumps the watchlist and/or whole library roster to JSON / CSV / text.
|
||||
- **ReplayGain Filler (#437)** and **Empty Folder Cleaner** library-maintenance jobs.
|
||||
- **#857** — custom in-container completed-download path for Torrent / Usenet so finished grabs in a category subfolder are found.
|
||||
- **HiFi instances** — Restore Defaults button, bigger tap targets, and a confirmed-working instance auto-pushed to existing installs (thanks Sokhi).
|
||||
- **Aria2** added to the torrent client list; artist detail **"DB Record"** inspector.
|
||||
|
||||
**Wishlist: only engage album-bundle when several tracks from the same album are missing.**
|
||||
New `core/wishlist/album_grouping.py`. Bundle path only engages when an album has ≥2 missing tracks; single-track items take the cheaper per-track path. Configurable via `wishlist.album_bundle_min_tracks`.
|
||||
## bug fixes
|
||||
|
||||
**Wishlist: distinguish Queued from Analyzing batches in the UI.**
|
||||
|
||||
**Album-bundle staging: clean Soulseek copies + sweep orphans at startup.**
|
||||
Cleanup gate extended to include `soulseek` (was torrent/usenet only). New `sweep_orphan_album_bundle_staging` runs once at server boot. **12 new tests**.
|
||||
|
||||
**Usenet album poll: tolerate SAB queue→history handoff (#706).**
|
||||
|
||||
**Discogs: strip artist disambiguation suffixes everywhere (#634).**
|
||||
|
||||
**Library: Enhanced / Standard view toggle persists per browser.**
|
||||
|
||||
**Fix popup: manual matches survive Playlist Pipeline runs.**
|
||||
|
||||
**Fix popup: artist + track fields no longer surface unrelated covers.**
|
||||
|
||||
### UX overhauls
|
||||
|
||||
**Dashboard enrichment panel — equalizer-bar redesign.** 11 speedometer tiles → 11 vertical VU-meter equalizer bars in one symmetric flex row. Brand-logo avatar disc above each bar (Spotify/Apple Music/Deezer/Last.fm/Genius/MusicBrainz/AudioDB/Tidal/Qobuz/Discogs/Amazon with initial-letter CDN-fail fallback); peak-flash on cpm step-up; rolling counter; glass-surface reflection puddle. Last.fm circle-clipped; Tidal/Qobuz/Discogs/Amazon inverted to white silhouettes.
|
||||
|
||||
**Auto-Sync manager — full visual overhaul.** Selector-based override layer (zero JS/HTML changes). Every surface inside the modal restyled to match the dashboard's glassy / accent-radial aesthetic.
|
||||
|
||||
**Auto-Sync — weekly board cards now match the hourly board.** Same Run-now button, unschedule X, next-run countdown, health badge. Weekly cards now draggable between day columns.
|
||||
|
||||
**Auto-Sync sidebar — brand logo on each source-group header.**
|
||||
|
||||
**Sync page tabs — brand-logo chips with active label pill.** 14 tabs collapsed from cramped labeled pills to circular brand-logo chips; active tab swells into a pill with its label inline. `Link` variants (Spotify Link / Deezer Link / iTunes Link) carry a small chain-link badge bottom-right.
|
||||
|
||||
### Architectural lifts
|
||||
|
||||
**Unified Playlist Sources layer.** `PlaylistSource` ABC + registry in `core/playlists/sources/`. Refresh handler dropped from ~190 lines of if/elif to ~80 lines. ListenBrainz / Last.fm / SoulSync Discovery are now Sync-page tabs.
|
||||
|
||||
**Auto-Sync schedule types — weekday + time.** New Weekly Board tab on the Auto-Sync manager.
|
||||
|
||||
**iTunes / Apple Music link import.** New iTunes Link tab on the Sync page.
|
||||
|
||||
---
|
||||
|
||||
## Test plan
|
||||
|
||||
- [x] 132 album-bundle + usenet tests pass (the new #721 path)
|
||||
- [x] 488 downloads tests pass (full suite)
|
||||
- [x] ~90 new unit tests across the cycle, including 9 new for #721
|
||||
- [x] Smoke: dashboard equalizer renders w/ brand logos, peak-flash on cpm increase
|
||||
- [x] Smoke: Auto-Sync manager renders glass overhaul, hourly + weekly cards both have action rows
|
||||
- [x] Smoke: Sync page tab strip renders as logo chips; active expands; Link variants show chain-link badge
|
||||
- [ ] Live: @IamGroot60 to re-test Forty Licks usenet bundle on dev (build with the #721 fix applied)
|
||||
- [ ] Live: Soulseek album download on a username-subdir slskd config completes cleanly (#715, user-validated post-merge)
|
||||
- [ ] Live: bundle staging dir cleaned on completion (user-validated post-merge)
|
||||
|
||||
---
|
||||
|
||||
## Post-merge checklist
|
||||
|
||||
- [ ] Tag `v2.6.4` on `main`
|
||||
- [ ] Trigger `docker-publish.yml` with `version_tag: 2.6.4` to push `boulderbadgedad/soulsync:2.6.4` + `ghcr.io/nezreka/soulsync:2.6.4` (default already updated)
|
||||
- [ ] Discord release announcement (auto-fired by the workflow)
|
||||
- [ ] Reply on #721 with the 2.6.4 release link
|
||||
- **#859** — a hung database update self-heals now instead of wedging on "Starting..." forever.
|
||||
- **#862** — Library Reorganize finally works on media-server libraries (falls back to tag mode when an album has no source ID).
|
||||
- **spotify (no-auth)** — shows as connected and the dashboard test reports it correctly, instead of claiming a deezer fallback.
|
||||
- **navidrome** reconnects itself instead of latching "disconnected"; the orphan detector hard-bails on a mass-orphan flood; plus more #852 lock-screen hardening and login-password management in Manage Profiles.
|
||||
|
|
|
|||
|
|
@ -167,6 +167,35 @@ def test_show_match_info(db):
|
|||
assert db.show_match_info(999999) is None
|
||||
|
||||
|
||||
def test_omdb_worker_processes_ratings_queue(db):
|
||||
db.upsert_movie("plex", {"server_id": "m1", "title": "A", "imdb_id": "tt1"})
|
||||
db.upsert_movie("plex", {"server_id": "m2", "title": "B"}) # no imdb_id → skipped
|
||||
|
||||
class Omdb:
|
||||
enabled = True
|
||||
def ratings(self, imdb_id):
|
||||
assert imdb_id == "tt1"
|
||||
return {"imdb_rating": 8.0, "rt_rating": 90, "metacritic": 77}
|
||||
|
||||
w = VideoEnrichmentWorker(db, "omdb", Omdb())
|
||||
assert w.is_ratings is True # ratings mode, not a matcher
|
||||
assert w.process_one() is True
|
||||
with db.connect() as c:
|
||||
r = c.execute("SELECT imdb_rating, ratings_synced FROM movies WHERE server_id='m1'").fetchone()
|
||||
assert r["imdb_rating"] == 8.0 and r["ratings_synced"] == 1
|
||||
assert db.ratings_next() is None # B has no imdb_id → nothing left
|
||||
assert w.process_one() is False
|
||||
|
||||
|
||||
def test_omdb_breakdown_is_ratings_coverage(db):
|
||||
a = db.upsert_movie("plex", {"server_id": "m1", "title": "A", "imdb_id": "tt1"}) # pending
|
||||
db.upsert_movie("plex", {"server_id": "m2", "title": "B", "imdb_id": "tt2"})
|
||||
db.apply_ratings("movie", a, {"imdb_rating": 8.0}) # one rated
|
||||
bd = db.enrichment_breakdown("omdb")
|
||||
assert bd["movie"]["matched"] == 1 and bd["movie"]["pending"] == 1
|
||||
assert "show" in bd
|
||||
|
||||
|
||||
def test_omdb_ratings_parse(monkeypatch):
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@ def test_dashboard_enrichment_buttons_present():
|
|||
block = _block(
|
||||
_INDEX, r'<section class="video-subpage" data-video-subpage="video-dashboard"', "</section>")
|
||||
assert 'data-video-enrich="tmdb"' in block and 'data-video-enrich="tvdb"' in block
|
||||
assert 'data-video-enrich="omdb"' in block # OMDb is a full worker too
|
||||
assert "data-video-manage-workers" in block
|
||||
assert "video-enrich-spinner" in block # spins while running
|
||||
assert "onclick" not in block
|
||||
|
|
|
|||
|
|
@ -470,6 +470,22 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-enrich-container" style="--ve-accent: 245, 197, 24;">
|
||||
<button class="video-enrich-button" type="button" data-video-enrich="omdb" title="OMDb Ratings">
|
||||
<span class="video-enrich-glyph" aria-hidden="true">★</span>
|
||||
<span class="video-enrich-spinner" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div class="video-enrich-tooltip" data-video-enrich-tooltip="omdb">
|
||||
<div class="tooltip-content">
|
||||
<div class="tooltip-header">OMDb Ratings</div>
|
||||
<div class="tooltip-body">
|
||||
<div class="tooltip-status">Status: <span data-video-enrich-status>Idle</span></div>
|
||||
<div class="tooltip-current" data-video-enrich-current>No active matches</div>
|
||||
<div class="tooltip-progress" data-video-enrich-progress>Progress: 0 / 0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reuses music's .em-manage-btn* classes verbatim so it's
|
||||
pixel-identical; wired by data-attribute (no inline
|
||||
handler) via video-enrichment.js, never music's. -->
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
var WORKERS = [
|
||||
{ id: 'tmdb', name: 'TMDB', color: '#38bdf8', rgb: '56, 189, 248', kinds: ['movie', 'show'] },
|
||||
{ id: 'tvdb', name: 'TVDB', color: '#a855f7', rgb: '168, 85, 247', kinds: ['show'] },
|
||||
{ id: 'omdb', name: 'OMDb', color: '#f5c518', rgb: '245, 197, 24', kinds: ['movie', 'show'] },
|
||||
];
|
||||
|
||||
function workerDef(id) {
|
||||
|
|
@ -116,8 +117,11 @@
|
|||
var pct = overallPct(s);
|
||||
var cov = pct == null ? '' :
|
||||
'<span class="em-rail-cov"><span class="em-rail-cov-fill" style="width:' + pct + '%"></span></span>';
|
||||
var icon = LOGOS[w.id]
|
||||
? '<img class="vem-logo vem-logo--' + w.id + '" src="' + LOGOS[w.id] + '" alt="">'
|
||||
: '<span class="vem-glyph" style="color:' + w.color + '">★</span>';
|
||||
return '<button class="em-worker-row" data-em-select="' + w.id + '" style="--row-accent: ' + w.rgb + '">' +
|
||||
'<span class="em-worker-icon"><img class="vem-logo vem-logo--' + w.id + '" src="' + LOGOS[w.id] + '" alt=""></span>' +
|
||||
'<span class="em-worker-icon">' + icon + '</span>' +
|
||||
'<span class="em-worker-meta"><span class="em-worker-name">' + esc(w.name) + '</span>' +
|
||||
'<span class="em-worker-sub">' + esc(railSub(s)) + '</span>' + cov + '</span>' +
|
||||
'<span class="em-dot em-dot--' + info.cls + '" title="' + info.label + '"></span></button>';
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
var SERVICES = ['tmdb', 'tvdb'];
|
||||
var SERVICES = ['tmdb', 'tvdb', 'omdb'];
|
||||
|
||||
function onVideoSide() {
|
||||
return document.body.getAttribute('data-side') === 'video';
|
||||
|
|
|
|||
|
|
@ -795,3 +795,12 @@ body[data-side="video"] .dashboard-header-sweep {
|
|||
.vd-rt--mc-good .vd-rt-tag { background: #6c3; color: #000; }
|
||||
.vd-rt--mc-mid .vd-rt-tag { background: #fc3; color: #000; }
|
||||
.vd-rt--mc-bad .vd-rt-tag { background: #f00; color: #fff; }
|
||||
|
||||
/* ── OMDb worker uses a ★ glyph (no clean brand logo) ─────────────────────── */
|
||||
.video-enrich-glyph {
|
||||
position: relative; z-index: 1; font-size: 22px; line-height: 1;
|
||||
color: rgb(var(--ve-accent, 245, 197, 24));
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4)); transition: transform 0.3s ease;
|
||||
}
|
||||
.video-enrich-button:hover .video-enrich-glyph { transform: scale(1.1); }
|
||||
#vem-overlay .vem-glyph { font-size: 20px; line-height: 1; }
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
const WORKER_DEFS = [
|
||||
{ sel: '[data-video-enrich="tmdb"]', color: [56, 189, 248], id: 'tmdb' },
|
||||
{ sel: '[data-video-enrich="tvdb"]', color: [168, 85, 247], id: 'tvdb' },
|
||||
{ sel: '[data-video-enrich="omdb"]', color: [245, 197, 24], id: 'omdb' },
|
||||
{ sel: '[data-video-manage-workers]', color: [168, 85, 247], hub: true },
|
||||
];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue