Compare commits
No commits in common. "main" and "2.6.4" have entirely different histories.
662 changed files with 10153 additions and 92679 deletions
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
|
|
@ -9,9 +9,9 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
version_tag:
|
||||
description: 'Version tag (e.g. 2.8.2)'
|
||||
description: 'Version tag (e.g. 2.6.4)'
|
||||
required: true
|
||||
default: '2.8.2'
|
||||
default: '2.6.4'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
|
|
|
|||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -12,12 +12,10 @@ __pycache__/
|
|||
|
||||
# User-specific files (auto-created by the app if missing)
|
||||
config/config.json
|
||||
config/youtube_cookies.txt
|
||||
# All app databases are live user data — never commit (music_library, video_library, …)
|
||||
database/*.db
|
||||
database/*.db-shm
|
||||
database/*.db-wal
|
||||
database/*.db.backup_*
|
||||
database/music_library.db
|
||||
database/music_library.db-shm
|
||||
database/music_library.db-wal
|
||||
database/music_library.db.backup_*
|
||||
database/api_call_history.json
|
||||
storage/image_cache/
|
||||
logs/*.log
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
# discover page — best in class plan (#913 + full generator audit)
|
||||
|
||||
morning notes. did the work overnight. tl;dr at top, details below, all of it `break nothing` + tested.
|
||||
|
||||
## what i shipped tonight (done, tested, safe)
|
||||
|
||||
### 1. listening recommendations (#913) — went from BROKEN to best-in-class
|
||||
|
||||
the feature was silently producing **zero** recs on real data. dug in and found three stacked bugs in the generation:
|
||||
|
||||
- **wrong id key (the killer).** `similar_artists.source_artist_id` is a *source* id (spotify/itunes/deezer), but the scanner built its id→name map from `artists.id` (the internal row id). so every edge resolved to nothing → 0 recs. proved it on your live db: internal-id join = 0 rows, spotify-id join = 71,636 rows.
|
||||
- **consensus could never fire.** it fed the ranker `get_top_similar_artists`, which does `GROUP BY similar_artist_name` + `MAX(source_artist_id)` — collapsing every similar artist down to a *single* seed. the whole point of the ranker is "artist X is similar to 3 of your seeds = strong signal," and that signal was being flattened away before it ever reached the ranker.
|
||||
- **similarity strength thrown away.** each edge stores a 1-10 closeness rank; it was ignored (everything weighted equally).
|
||||
|
||||
the fix (all in the pure, tested core + thin scan wiring):
|
||||
- build id→name from the **source-id columns**, query the **raw per-seed edges** (consensus preserved), and thread **similarity_rank** into the score so a seed's closest matches count for more.
|
||||
- **recency-weighted seeds**: `weight = lifetime_plays + 1.5 × recent_30d_plays`. picks now track what you're into *now*, not just all-time totals.
|
||||
|
||||
result on your actual library (simulated through the real code path): **40 recommendations, 13 with multi-seed consensus, all 40 with cached art.** top picks: Arcangel (Bad Bunny + Ozuna + J Balvin), Melanie Martinez (Ariana + Billie), Maluma, De La Ghetto — all coherent, all explainable.
|
||||
|
||||
### 2. its own row on the discover page
|
||||
|
||||
new row **"Based On Your Listening"** — play-weighted, consensus-ranked artist cards with a **"Because you listen to X, Y"** line. sits right above the library-driven "Recommended For You" row. purely additive: new endpoint `/api/discover/listening-recommendations`, new loader, hides itself when empty.
|
||||
|
||||
**you need to run one watchlist scan** for the row to populate (the data regenerates during the scan — i did NOT touch your live db). before that scan the row just stays hidden; after it, it fills in.
|
||||
|
||||
> note: this is deliberately different from the existing "Recommended For You" row. that one is driven by your *whole library / watchlist*. this one is driven by your *actual listening intensity* — the ~30 artists you really play, not the thousands you happen to own.
|
||||
|
||||
### 3. Fresh Tape "only 5-10 tracks" — fixed
|
||||
|
||||
root cause: `get_discovery_recent_albums` orders `release_date DESC`, so announced-but-unreleased albums sort to the *top* and ate the 50-album budget. the scanner skipped them *after* the budget was already spent → only a handful of released albums left → 5-10 tracks. fixed by fetching a generous budget (300) **and** excluding next-year albums at the query, so released albums fill the budget. the precise same-year `is_future_release` skip stays as a second guard. downstream caps (6/artist, top 75, take 50) unchanged.
|
||||
|
||||
**tests:** 25 pure-core cases (consensus/similarity/recency) + 2 Fresh Tape regression tests, all green. full discovery suite (255) green. nothing else touched.
|
||||
|
||||
---
|
||||
|
||||
## best-in-class roadmap for listening recs (next phases — your call)
|
||||
|
||||
these are the levers to take it further. ordered by value-to-risk. none are required; tonight's work stands on its own.
|
||||
|
||||
| phase | what | value | risk | notes |
|
||||
|---|---|---|---|---|
|
||||
| **3** | **playable track row** ✅ DONE | high | low-med | shipped: "🎧 Your Listening Mix" row — a track playlist (play/queue/download/sync) right under the artist row. stored as full render-ready dicts (not pool-hydrated, so it can't shrink on pool rotation like Fresh Tape does). |
|
||||
| **4** | **direct top-tracks fetch** ✅ DONE | high | med | shipped: scan fetches each recommended artist's top tracks (Spotify/Deezer), resolving the artist id by name-search when the similar-artist row lacks one — guarded by a strict name-match so it never pulls the wrong artist. bounded (top 20 recs), per-call guarded, fail-soft to the pool. iTunes has no top-tracks API → pool-only there. needs a live scan to populate. |
|
||||
| **5** | **genre-affinity boost** | med | low | we already compute your genre breakdown. boost recs whose genres match your top genres → tighter taste alignment. pure scoring add. |
|
||||
| **6** | **adventurousness dial** | med | low | the ranker already supports `min_seed_count` (consensus floor). expose it as a "Safe ↔ Adventurous" slider on the row. |
|
||||
| **7** | **diversity pass** | low-med | low | avoid 40 recs all orbiting your single heaviest seed — cap picks-per-seed so the row spans your taste. |
|
||||
|
||||
the core is built to absorb all of these without re-plumbing — `similarity_from_rank`, `build_recency_weighted_seeds`, and the scoring formula are all pure + tested.
|
||||
|
||||
---
|
||||
|
||||
## full discover-page generator audit (every soulsync-built row, excluding last.fm + listenbrainz)
|
||||
|
||||
how each one is generated today, and whether it can be elevated. "clear win" = safe + additive. "product call" = needs your decision (changes the row's character).
|
||||
|
||||
### curated (built during the scan, then hydrated)
|
||||
- **Fresh Tape / Release Radar** — new releases from watchlist+similar artists. **FIXED tonight** (see above). one more *clear win* available: hydration silently drops any curated id no longer in the discovery pool — could fall back to the stored `track_data_json` blob so the row can't shrink at read time.
|
||||
- **The Archives / Discovery Weekly** — strong already. nice 3-tier popularity split + serendipity scoring (boost never-played artists, penalize overplayed). same hydration-drop caveat as Fresh Tape; same cheap fallback fix.
|
||||
- **Seasonal Mix** — cleanest of the bunch. hydrates from a dedicated `seasonal_tracks` table (carries its own data), so it doesn't suffer the pool-drop problem. no bug.
|
||||
|
||||
### discovery-pool generators (live queries)
|
||||
- **Popular Picks** — ranks by popularity DESC. solid. only nit: on iTunes (no popularity scale) it silently degrades to random — indistinguishable from Shuffle there. UI-label thing at most.
|
||||
- **Hidden Gems** — *clear win*. currently `ORDER BY RANDOM()` over low-popularity tracks — so it's "random obscure," not "*best* obscure." a light ranking (popularity just under the threshold, or genre-affinity to you) would make it feel curated instead of arbitrary. (a deeper *product call*: add personalization like Archives has — bigger lift, changes its "pure underground" character.)
|
||||
- **Genre Playlists** — good. pushes the genre match into SQL. `RANDOM()` ordering is fine for a browse; a popularity/affinity tiebreak (*clear win*) would make thin genres feel less arbitrary.
|
||||
- **Discovery Shuffle** — random by design, correct. only possible add: exclude tracks already shown in other rows this refresh (needs a cross-section seen-set — medium plumbing).
|
||||
- **Time Machine (by decade)** — *clear win, low risk*: decades are hardcoded, so a modern-only library shows 7 decade tabs, 5 empty. filter the tabs to decades that actually have pool data.
|
||||
- **Daily Mix** — the weakest row. the "50% your library" half permanently returns nothing (library tracks have no source ids to play), so each Daily Mix is really just a relabeled Genre Playlist. real fix = backfill source ids into library rows (*schema-level, higher risk*) — worth a dedicated pass, not a quick tweak. also silently falls back to "top artists as pseudo-genres" when genre data is missing → "Daily Mix 1" becomes mislabeled artist-radio. gate/label that (*clear win*).
|
||||
|
||||
### cross-cutting
|
||||
- **hydration fragility** (Fresh Tape + Archives): both depend on curated ids still living in the pool at read time; misses are dropped silently. Seasonal already solved this with a dedicated table. giving the two spotify-style rows the same data-blob fallback is the single most robust cross-cutting fix. low risk, clear win.
|
||||
- **RANDOM-ordering pattern** (Hidden Gems, Shuffle, Genre, Decade): intentional for variety, but leaves quality signal on the table for the non-shuffle rows. adding a light ranking pass to Hidden Gems + Genre is the biggest "best-in-class" lever after tonight's work.
|
||||
|
||||
want me to take any of these? the Hidden Gems ranking + Time Machine empty-decade filter + the Fresh Tape/Archives hydration fallback are all safe, additive, same-shape-as-tonight wins i can knock out next.
|
||||
23
Dockerfile
23
Dockerfile
|
|
@ -29,16 +29,6 @@ COPY requirements.txt .
|
|||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# yt-dlp must track YouTube faster than its stable channel ships — stable can
|
||||
# lag months behind a breaking YouTube change while extraction is broken
|
||||
# ("Requested format is not available"). Build images with the NIGHTLY channel.
|
||||
# COMMIT_SHA is referenced in the RUN so CI's layer cache (cache-from: gha)
|
||||
# busts on every new commit — otherwise this layer could pin a stale "nightly"
|
||||
# for months, silently defeating its purpose.
|
||||
ARG COMMIT_SHA=""
|
||||
RUN echo "yt-dlp nightly for build ${COMMIT_SHA}" && \
|
||||
pip install --no-cache-dir -U --pre "yt-dlp[default]"
|
||||
|
||||
# Stage 2: Runtime — only runtime dependencies, no build tools
|
||||
FROM python:3.11-slim
|
||||
|
||||
|
|
@ -54,25 +44,14 @@ ENV PATH="/opt/venv/bin:$PATH"
|
|||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime-only system dependencies (no gcc/build tools).
|
||||
# unzip is needed by the Deno installer below.
|
||||
# Install runtime-only system dependencies (no gcc/build tools)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
gosu \
|
||||
ffmpeg \
|
||||
libchromaprint-tools \
|
||||
unzip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Deno — JavaScript runtime for yt-dlp. YouTube gates its downloadable formats
|
||||
# behind JS challenges (nsig); without a JS runtime, yt-dlp's extraction is
|
||||
# deprecated and streams / music-video downloads fail with "Requested format
|
||||
# is not available". Deno is yt-dlp's default-enabled runtime; the official
|
||||
# installer auto-detects amd64/arm64. `deno --version` fails the build early
|
||||
# if the install ever breaks.
|
||||
RUN curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh && \
|
||||
deno --version
|
||||
|
||||
# Create non-root user for security
|
||||
RUN useradd --create-home --shell /bin/bash --uid 1000 soulsync
|
||||
|
||||
|
|
|
|||
21
README.md
21
README.md
|
|
@ -105,21 +105,6 @@ SoulSync bridges streaming services to your music library with automated discove
|
|||
- Catches wrong versions (live, remix, cover) even from streaming API sources
|
||||
- Fail-open design: verification errors never block downloads
|
||||
|
||||
#### AcoustID API key
|
||||
|
||||
AcoustID verification is opt-in. To enable it, request a free API key
|
||||
at <https://acoustid.org/new-application> and paste it into
|
||||
Settings → AcoustID. Without a key, downloads still complete but the
|
||||
verification step is skipped silently.
|
||||
|
||||
If a track was previously tagged by AcoustID but the retag action in
|
||||
the AcoustID Scanner no longer changes anything, see issue #704 — the
|
||||
most common cause is that the file already carries a
|
||||
`MUSICBRAINZ_TRACKID` tag, which the retag step uses as a short-circuit
|
||||
and therefore never overwrites. Removing the cached
|
||||
`MUSICBRAINZ_TRACKID` (and the `ACOUSTID_ID` if present) from the file
|
||||
restores the retag.
|
||||
|
||||
### Metadata & Enrichment
|
||||
|
||||
**10 Background Enrichment Workers**: Spotify, MusicBrainz, iTunes, Deezer, Discogs, AudioDB, Last.fm, Genius, Tidal, Qobuz
|
||||
|
|
@ -317,11 +302,6 @@ cd ..
|
|||
|
||||
If `webui/static/dist/.vite/manifest.json` is missing or stale, React-owned routes and route handoffs may not load correctly.
|
||||
|
||||
**YouTube streaming / music videos** need two extra things on bare-metal installs (Docker bundles both):
|
||||
|
||||
- **Deno** — yt-dlp now requires a JavaScript runtime to unlock YouTube formats. Without it, streams and music-video downloads fail with `Requested format is not available`. Install: `winget install DenoLand.Deno` (Windows) or see [deno.com](https://docs.deno.com/runtime/), then restart SoulSync.
|
||||
- **yt-dlp nightly** — the stable release can lag months behind YouTube changes. If YouTube breaks, update with: `python -m pip install -U --pre "yt-dlp[default]"`
|
||||
|
||||
### Local Development
|
||||
|
||||
This is only for contributors working on the WebUI with hot reload. Normal Python/no-Docker installs should build once with `npm run build` as shown above, then run only Gunicorn.
|
||||
|
|
@ -360,7 +340,6 @@ on any OS. `./dev.sh` remains available as a Unix shell wrapper.
|
|||
- **slskd** running and accessible ([Download](https://github.com/slskd/slskd/releases)) — required for Soulseek downloads
|
||||
- **Spotify API** credentials ([Dashboard](https://developer.spotify.com/dashboard)) — optional but recommended for discovery
|
||||
- **Media Server** (optional): Plex, Jellyfin, or Navidrome
|
||||
- **Deno** (Python/no-Docker installs only): JavaScript runtime required by yt-dlp for YouTube streaming/music videos — `winget install DenoLand.Deno` or [deno.com](https://docs.deno.com/runtime/). Docker images bundle it.
|
||||
- **Deezer ARL token** (optional): For Deezer downloads — get from browser cookies after logging into deezer.com
|
||||
- **Tidal account** (optional): For Tidal downloads — authenticate via device flow in Settings
|
||||
- **Qobuz account** (optional): For Qobuz downloads — email/password login in Settings
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
**SoulSync 2.7.9** is out 🎉 a big one.
|
||||
|
||||
🎚️ **Best-quality downloads** — downloads now follow a ranked quality profile you drag to order (FLAC 24/192 → mp3). best-quality mode grabs the highest-quality copy across *every* source; priority mode gets an opt-in rank-based order toggle. quarantine is folded into the Downloads page + safer imports (AcoustID fail-closed, silence/truncation guards).
|
||||
|
||||
🎧 **Discover got smart** — "Based On Your Listening" ranks artists from who you *actually play*, and "Your Listening Mix" is a playable track playlist of their top tracks (works on any source). Fresh Tape fills properly now.
|
||||
|
||||
⚡ **Wing It Pool** — a new spot next to Discovery Pool to review + re-match the tracks Wing It guessed at (they used to be invisible).
|
||||
|
||||
🔁 **Auto-Sync redesign** — the scheduling board is now clean horizontal lanes instead of a side-scrolling column wall.
|
||||
|
||||
🐛 **Fixes** — multi-disc albums no longer show disc-2 as "missing" (#927), playlists no longer stuck on "Never Synced" (#925), and tracks can't import while quarantined (#928). thanks @ramonskie + @nick2000713 🙏
|
||||
|
||||
⚠️ **re-scan your library once** so the multi-disc fix can backfill disc numbers on existing tracks. enjoy! 🎶
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
**SoulSync 2.8.0** is out 🎉 a quality + reliability release.
|
||||
|
||||
🧹 **The Unverified queue, finally under control** — if you saw thousands of "unverified" rows piling up, this is for you. the AcoustID scan stops duplicating history rows, a one-time reconcile on startup clears the existing backlog from your library (no re-scan), and a new 🧹 *Clean orphaned* button sweeps dead rows whose file is gone. (#934 — thanks @nick2000713 for #938)
|
||||
|
||||
✂️ **Preview Clip Cleanup** — a new Tools job that finds the ~30s preview clips the HiFi source sometimes hands back instead of the full song, then deletes them and re-wishlists the real version. each finding has a ▶ Play button so you can confirm before approving.
|
||||
|
||||
💿 **Album Completeness handles split albums** — an album split across multiple library rows no longer shows every fragment as falsely "incomplete"; it groups the validated fragments into one correct finding. (#936 — thanks @ragnarlotus)
|
||||
|
||||
🐛 **Fixes** — pasted YouTube cookies no longer throw `unsupported browser: "custom"` on Docker (thanks HellRa1SeR); longer remasters aren't quarantined as "truncated" anymore (#937, thanks @diegocade1); "Add to Wishlist" from a discography went from ~15–30s *per track* to instant; wishlist art renders for re-downloads; and **Clear Completed** is back on the Downloads page.
|
||||
|
||||
⚡ **Performance** — trimmed the dashboard GPU usage that was hammering Firefox/Zen (and Background Particles are OFF by default now), plus bounded the runaway memory growth that could lock the app up on big libraries. (#935 / #802)
|
||||
|
||||
enjoy! 🎶
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
**SoulSync 2.8.1** is out 🎉 a feature + reliability release.
|
||||
|
||||
🎧 **Export playlists to Spotify & Deezer** — the mirrored-playlist export now has **Sync to Spotify** and **Sync to Deezer** next to the ListenBrainz / JSPF options. it builds a playlist in your account from the IDs soulsync already has (the discovery cache first, then your library), so an already-discovered playlist exports **instantly with zero API calls**. re-exporting updates the same playlist instead of duplicating it, and an optional *"match missing tracks"* toggle confidently searches for the stragglers — a wrong-artist or karaoke version is left out, never guessed. the first Spotify export asks permission once. (#945)
|
||||
|
||||
🏷️ **Library Reorganize — Rename only** — a lighter action that just **renames your files** to your naming scheme: no re-tag, no quality/AcoustID re-check, no copy-to-staging. much faster on a NAS, and only touches files whose path actually changes. pick it from the new Action dropdown. (#875 — thanks @tsoulard / @Tacobell444)
|
||||
|
||||
💿 **Broader lossless handling** — lossy-copy now covers **all lossless formats**, not just FLAC (#941); and **DSD** (`.dsf`/`.dff`) is recognized as lossless instead of false-flagged "truncated" (#939).
|
||||
|
||||
🐛 **Download + search fixes** — an unbalanced bracket no longer false-fails as "file not found"; a file we couldn't quarantine is left for retry instead of deleted; "file not found" errors are actionable now; pasted Qobuz/Tidal links inject the exact track into manual search (#932); the Wing It pool "Fix Match" works again.
|
||||
|
||||
⚡ **Reduce visual effects, refined** — it no longer freezes functional motion (spinners, progress), only the expensive GPU stuff (blur, shadows, glow). worker orbs default OFF on Firefox and run at ~30fps under reduce-effects. plus a jellyfin scan watchdog fix for big libraries.
|
||||
|
||||
🔧 **Under the hood** — settings cleanup (#943, @nick2000713), spotify oauth hardening (#942) + npm security fixes (#944, HellRa1SeR).
|
||||
|
||||
enjoy! 🎶
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
**SoulSync 2.8.2** is out 🎉 a stability + performance release.
|
||||
|
||||
🎧 **Spotify, reliably** — the Docker boot hang is fixed: with Spotify as your primary source, an unreachable Spotify API could block startup so the container bound `:8008` but never served the UI. auth probes are now deferred during boot + capped with a timeout. the "re-auth didn't stick" bug is fixed too (the OAuth callback and the app were reading different token caches), and **Sync to Spotify** now works — it asks for playlist-write permission once, on-demand, leaving your normal login untouched. (#949 — thanks HellRa1SeR)
|
||||
|
||||
⚡ **The "slow after update" fix** — the post-update lag wasn't SoulSync, it was browser password managers (Bitwarden/1Password/etc.) rebuilding their autofill overlay on *every* DOM change. non-credential fields are now marked so they skip them — **~110× less main-thread blocking** in the reporter's benchmark. plus a new **Max Performance** mode (Settings → Appearance) that kills every effect for no-GPU / Docker setups. (#948 — thanks @nick2000713)
|
||||
|
||||
📥 **Large-library imports no longer time out** — dropping a whole library into staging used to make the import page scan every file synchronously and never load. the scan runs in the background now with a live "Scanning N of M…" progress, and fills in when done. (#947 — thanks @ramonskie)
|
||||
|
||||
enjoy! 🎶
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
# Spec: Canonical Album Version (fixes #765 + #767-Bug2)
|
||||
|
||||
**Status:** design only — no code yet.
|
||||
**Goal:** Pin ONE canonical `(source, album_id)` per album, chosen by best-fit to
|
||||
the user's actual files, so the Library Reorganizer, Track Number Repair, and
|
||||
tagging/enrichment all agree on the same release. Today each re-resolves
|
||||
independently and they contradict each other (Spotify Believer=4 vs MusicBrainz
|
||||
Believer=3; standard album mislinked to a deluxe release).
|
||||
|
||||
**Canonical-selection rule (decided):** *match the user's actual files.* The
|
||||
canonical release is the candidate whose track count + per-track durations +
|
||||
titles best fit what's on disk. Self-correcting: picks standard when you own the
|
||||
standard, deluxe when you own the deluxe.
|
||||
|
||||
---
|
||||
|
||||
## Hard requirement: don't disrupt the running app
|
||||
|
||||
Every stage below is **additive and dormant until explicitly consumed**, and
|
||||
every consumer **falls back to today's behavior when no canonical is set**. So:
|
||||
- albums with no resolved canonical behave EXACTLY as they do now;
|
||||
- each stage is independently shippable and reversible;
|
||||
- nothing big-bangs.
|
||||
|
||||
---
|
||||
|
||||
## Stage 1 — Schema + pure scorer (ships dormant, zero behavior change)
|
||||
|
||||
### Schema (additive, nullable → migration-safe)
|
||||
Add to `albums` (guarded `ALTER TABLE ... ADD COLUMN`, idempotent — mirror the
|
||||
existing column-exists checks; see [[db-schema-review]] migration-safety notes):
|
||||
- `canonical_source TEXT` — e.g. 'spotify' / 'itunes' / 'musicbrainz'
|
||||
- `canonical_album_id TEXT`
|
||||
- `canonical_score REAL` — best-fit score (for transparency / re-resolve gating)
|
||||
- `canonical_resolved_at TIMESTAMP`
|
||||
|
||||
All nullable. Existing rows = NULL → "unresolved" → consumers fall back. No
|
||||
backfill in this stage. No reads in this stage.
|
||||
|
||||
### Pure core helper (the testable heart) — `core/metadata/canonical_version.py`
|
||||
```
|
||||
score_release_against_files(file_tracks, release_tracks) -> float
|
||||
pick_canonical_release(file_tracks, candidates) -> (best, score) | (None, 0)
|
||||
```
|
||||
- `file_tracks`: list of {duration_ms, title, track_number?} read from disk.
|
||||
- `release_tracks`: a candidate release's tracklist (same shape).
|
||||
- Scoring (tunable weights):
|
||||
- **track-count fit** — exact match strongly preferred; |Δcount| penalized.
|
||||
- **duration alignment** — greedily match each file to its closest release
|
||||
track by duration (within a tolerance, e.g. ±3s); reward coverage.
|
||||
- **title overlap** — token/fuzzy overlap as a tiebreaker.
|
||||
- **graceful degradation** — if a source gives no per-track durations, fall
|
||||
back to count + title only (never crash, never force-pick).
|
||||
- Returns the best candidate + score, or (None, 0) when nothing clears a floor
|
||||
(so we never pin a bad guess — leave it unresolved, consumers fall back).
|
||||
|
||||
### Tests (extreme, like the rest of this codebase)
|
||||
- standard (11) vs deluxe (17) with 11 files on disk → picks standard.
|
||||
- same album, 17 files → picks deluxe.
|
||||
- duration disambiguation when track counts tie (e.g. radio edit vs album).
|
||||
- missing-duration source → count+title fallback still picks sanely.
|
||||
- no candidate clears the floor → (None, 0).
|
||||
- "Believer" standard(=track 3 listing) vs Spotify(=4) with the user's files →
|
||||
whichever the files actually match.
|
||||
|
||||
**End of Stage 1: scorer exists + tested, columns exist, NOTHING reads/writes
|
||||
them yet. Provably zero behavior change.**
|
||||
|
||||
---
|
||||
|
||||
## Stage 2 — Resolver populates canonical (writes, still no consumers)
|
||||
|
||||
A function `resolve_canonical_for_album(album_id, db, ...)`:
|
||||
1. Gather on-disk file metadata for the album (durations/titles) via the
|
||||
library's known file paths.
|
||||
2. Gather candidate releases: every source the album has an ID for
|
||||
(spotify/itunes/deezer/discogs/soul/musicbrainz) AND — for the deluxe/standard
|
||||
case — sibling editions discoverable from those. Fetch each tracklist
|
||||
(cached, rate-limited).
|
||||
3. `pick_canonical_release(files, candidates)` → store `(source, album_id, score)`
|
||||
on the album row if it clears the floor.
|
||||
|
||||
Wiring: a small **backfill repair job** (dry-run-capable) + a hook in enrichment
|
||||
when an album is (re)enriched. Still **no tool READS canonical**, so behavior is
|
||||
unchanged — this stage only populates the new columns. Reversible: clearing the
|
||||
columns reverts to unresolved.
|
||||
|
||||
Tests: resolver picks the right release for the standard/deluxe fixtures; stores
|
||||
nothing when below floor; idempotent re-resolve.
|
||||
|
||||
Cost note: fetching multiple candidate releases = more API calls. Mitigate via
|
||||
cache + only-on-(re)enrich + the existing rate trackers. Surface in the job's
|
||||
progress so it's not silent.
|
||||
|
||||
---
|
||||
|
||||
## Stage 3 — Reorganizer reads canonical (first real behavior change, gated)
|
||||
|
||||
In `library_reorganize._resolve_source`: if the album has
|
||||
`canonical_source`/`canonical_album_id`, use THAT first; else fall back to the
|
||||
current `get_source_priority` walk. One-line precedence change, fully gated on
|
||||
non-NULL.
|
||||
|
||||
Tests: with canonical set → resolves to it; with canonical NULL → byte-identical
|
||||
to today. Re-run the existing reorganize battery (148 tests) — must stay green.
|
||||
|
||||
**This alone fixes #767-Bug2** (a standard album whose files match the standard
|
||||
release pins the standard, so reorganize stops targeting the deluxe folder).
|
||||
|
||||
---
|
||||
|
||||
## Stage 4 — Track Number Repair reads canonical (closes #765)
|
||||
|
||||
In `track_number_repair._resolve_album_tracklist`: add **Fallback -1** (before
|
||||
everything) — if the album has a canonical `(source, album_id)`, use it. The
|
||||
existing 6-level cascade stays as the fallback for albums with no canonical
|
||||
(preserves its all-01-album rescue ability — the regression risk we refused to
|
||||
take in the reactive fix).
|
||||
|
||||
Now both tools resolve the SAME release → same track numbers → no contradiction.
|
||||
|
||||
Tests: canonical present → both tools agree (shared-release test); canonical
|
||||
NULL → existing cascade unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Risks & mitigations
|
||||
- **Extra API calls** (Stage 2 fetches multiple releases) → cache, rate-limit,
|
||||
only-on-(re)enrich, progress-logged.
|
||||
- **Sources without per-track durations** → scorer degrades to count+title.
|
||||
- **Schema migration** → additive nullable columns only; idempotent guards.
|
||||
- **Wrong pick** → floor gate (never pin a low-confidence guess); `canonical_score`
|
||||
stored for inspection/re-resolve; manual override possible later.
|
||||
- **Backward-compat** → every consumer falls back to today's path when NULL, so
|
||||
un-resolved albums (incl. all existing albums until backfilled) are unaffected.
|
||||
|
||||
## Out of scope (for now)
|
||||
- Per-album manual version override UI (can layer on later — the columns support it).
|
||||
- Merging the two tools into one (the reporter's alt suggestion) — unnecessary
|
||||
once they share the canonical.
|
||||
|
||||
## Suggested order to build
|
||||
1, then 2, then 3, then 4 — each shippable and verifiable on its own. We can stop
|
||||
after any stage and the app is consistent (just with fewer consumers wired).
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
# Running SoulSync behind a reverse proxy (nginx / Caddy / Traefik)
|
||||
|
||||
Putting SoulSync behind a reverse proxy lets you serve it over **HTTPS** and — the
|
||||
important part — put **authentication** in front of it before exposing it to the
|
||||
internet. This guide covers the safe setup.
|
||||
|
||||
> **The golden rule:** the safest way to expose *any* self-hosted app publicly is
|
||||
> to require authentication at the proxy (an auth layer), **not** to rely on the
|
||||
> app's own protection. SoulSync's launch PIN is a useful fallback, but it is not
|
||||
> a substitute for a real auth layer on a public instance.
|
||||
|
||||
---
|
||||
|
||||
## 1. Turn on reverse-proxy mode
|
||||
|
||||
By default SoulSync does **not** trust proxy headers (so a direct client can't spoof
|
||||
its IP or pretend the connection is HTTPS). If you're behind a proxy that
|
||||
terminates TLS, turn on **Settings → Security → "Behind a reverse proxy"** and
|
||||
**restart SoulSync** (this option applies at startup).
|
||||
|
||||
When enabled, SoulSync:
|
||||
- trusts `X-Forwarded-For/Proto/Host/Port` from **one** proxy hop (correct client
|
||||
IP, HTTPS detection, redirects),
|
||||
- marks its session cookie `Secure` (HTTPS-only) + `SameSite=Lax`, and
|
||||
- sends conservative security headers (`X-Content-Type-Options: nosniff`,
|
||||
`X-Frame-Options: SAMEORIGIN`, `Strict-Transport-Security`). No CSP is set — tune
|
||||
one at your proxy if you want it.
|
||||
|
||||
**Leave it off if you access SoulSync directly over http:// on your LAN** — turning
|
||||
it on would make the session cookie HTTPS-only and break plain-HTTP access. With it
|
||||
off, none of the above applies and SoulSync behaves exactly as before.
|
||||
|
||||
> The launch PIN is also brute-force limited (10 wrong attempts from an IP → a
|
||||
> short cooldown), regardless of this setting — a correct PIN is never affected.
|
||||
|
||||
Restart SoulSync after changing it.
|
||||
|
||||
---
|
||||
|
||||
## 2. nginx
|
||||
|
||||
SoulSync uses WebSockets (Socket.IO), so the `Upgrade`/`Connection` headers are
|
||||
**required** — without them live updates silently stop working.
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name soulsync.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/soulsync.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/soulsync.example.com/privkey.pem;
|
||||
|
||||
# Large library scans / uploads
|
||||
client_max_body_size 0;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8008;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# Required for Socket.IO / live updates
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
proxy_read_timeout 3600s; # long-running scans
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Caddy
|
||||
|
||||
Caddy handles TLS automatically and proxies WebSockets out of the box:
|
||||
|
||||
```caddy
|
||||
soulsync.example.com {
|
||||
reverse_proxy 127.0.0.1:8008
|
||||
}
|
||||
```
|
||||
|
||||
Caddy sets `X-Forwarded-*` for you. (Add an auth provider directive if you want
|
||||
auth at the proxy — see below.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Traefik
|
||||
|
||||
Traefik proxies WebSockets automatically and forwards the headers. Point a router
|
||||
at the SoulSync service on port `8008` with your TLS resolver; no extra WebSocket
|
||||
config is needed.
|
||||
|
||||
---
|
||||
|
||||
## 5. Add authentication in front (recommended for public instances)
|
||||
|
||||
Pick one:
|
||||
|
||||
- **Auth proxy** — [Authelia](https://www.authelia.com/),
|
||||
[Authentik](https://goauthentik.io/), or
|
||||
[oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/). These sit in front
|
||||
of SoulSync and force a login (with 2FA) before any request reaches it. Best
|
||||
option for internet exposure.
|
||||
|
||||
SoulSync can **trust the proxy's authenticated-user header** so the launch PIN is
|
||||
skipped once the proxy has logged you in. Set the header name in **Settings →
|
||||
Security → "Auth proxy user header"** (e.g. `Remote-User`).
|
||||
|
||||
> ⚠️ **Only enable this behind a proxy you control that STRIPS any client-supplied
|
||||
> copy of that header.** Otherwise a direct visitor could send `Remote-User: admin`
|
||||
> and walk straight in. It's **off by default** — an unset header name means
|
||||
> SoulSync ignores the header entirely (a spoofed one does nothing).
|
||||
- **HTTP Basic Auth** — quick and simple (nginx `auth_basic` / Caddy `basicauth`).
|
||||
Better than nothing; weaker than an auth proxy.
|
||||
- **SoulSync launch PIN** — set an admin PIN in Settings. Enforced server-side, so
|
||||
it can't be bypassed by hitting the API directly — but it's a shared PIN, so
|
||||
treat it as a fallback, not your only gate.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Live updates / progress bars don't move** → the WebSocket `Upgrade`/`Connection`
|
||||
headers are missing (nginx) or your proxy is buffering. Check section 2.
|
||||
- **Login won't stick / "session expired"** → you enabled `trust_reverse_proxy` but
|
||||
are reaching SoulSync over plain `http://`. The session cookie is now HTTPS-only;
|
||||
use `https://`, or turn the setting off for direct HTTP access.
|
||||
- **Scans time out** → raise `proxy_read_timeout` / `proxy_send_timeout`.
|
||||
|
|
@ -44,8 +44,7 @@
|
|||
},
|
||||
"metadata_enhancement": {
|
||||
"enabled": true,
|
||||
"embed_album_art": true,
|
||||
"single_to_album": false
|
||||
"embed_album_art": true
|
||||
},
|
||||
"file_organization": {
|
||||
"enabled": true,
|
||||
|
|
@ -57,11 +56,6 @@
|
|||
"playlist_path": "$playlist/$artist - $title"
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"staging_path": "./Staging",
|
||||
"replace_lower_quality": false,
|
||||
"folder_artist_override": true
|
||||
},
|
||||
"lossy_copy": {
|
||||
"enabled": false,
|
||||
"bitrate": "320",
|
||||
|
|
@ -73,4 +67,4 @@
|
|||
"listenbrainz": {
|
||||
"token": "LISTENBRAINZ_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -66,12 +66,6 @@ class ConfigManager:
|
|||
|
||||
self._load_config()
|
||||
|
||||
# Placeholder shipped to the browser in place of a configured secret
|
||||
# (#832 follow-up). The settings UI shows it as masked dots; if it's
|
||||
# round-tripped back on save, ``set()`` treats it as "keep existing" so the
|
||||
# real value is never overwritten by the mask.
|
||||
REDACTED_SENTINEL = '__redacted_unchanged__'
|
||||
|
||||
# Dot-notation paths to sensitive config values that must be encrypted at rest.
|
||||
# Paths pointing to dicts encrypt the entire dict as a JSON blob.
|
||||
_SENSITIVE_PATHS = frozenset({
|
||||
|
|
@ -499,48 +493,6 @@ class ConfigManager:
|
|||
# editing source.
|
||||
"album_bundle_poll_interval_seconds": 2.0,
|
||||
"album_bundle_timeout_seconds": 6 * 60 * 60, # 6 hours
|
||||
# Stalled-torrent handling (noldevin): abandon a torrent that
|
||||
# makes zero download progress for this long (dead magnet
|
||||
# stuck on "downloading metadata", no seeders) instead of
|
||||
# holding the worker for the full album timeout. 0 disables.
|
||||
"torrent_stall_timeout_seconds": 10 * 60, # 10 minutes
|
||||
# What to do when a torrent stalls: "abandon" (remove it +
|
||||
# its partial data, fail the download so the next source can
|
||||
# try) or "pause" (pause in the client, leave for the user).
|
||||
"torrent_stall_action": "abandon",
|
||||
# Where THIS container can read completed torrent/usenet
|
||||
# downloads (#857). The downloader (qBit/SAB) reports a save
|
||||
# path from inside ITS OWN container — often a category folder
|
||||
# like /data/downloads/music — which may be mounted at a
|
||||
# different point here. Set these to the in-container path(s)
|
||||
# where SoulSync sees those finished downloads; the resolver
|
||||
# then finds the release by name under them. Empty = fall back
|
||||
# to the soulseek download/transfer dirs (the shared-volume
|
||||
# default). See core.download_plugins.album_bundle.resolve_reported_save_path.
|
||||
"torrent_download_path": "",
|
||||
"usenet_download_path": "",
|
||||
# Explicit remote→local prefix mappings for non-shared / oddly
|
||||
# mounted layouts (Sonarr/Radarr "Remote Path Mapping" style):
|
||||
# a list of {"from": "<client path>", "to": "<soulsync path>"}.
|
||||
# Tried before the basename fallback above.
|
||||
"usenet_path_mappings": [],
|
||||
},
|
||||
"post_processing": {
|
||||
# When a download is quarantined (AcoustID mismatch, integrity /
|
||||
# duration failure), retry the next-best candidate instead of
|
||||
# failing outright. Default ON (PR #801's documented default —
|
||||
# the monitor reads this with inline default True; this template
|
||||
# said False, so fresh installs silently shipped with the retry
|
||||
# engine off while existing configs got it on. CI caught the
|
||||
# split: its fresh default config failed all 7 requeue tests).
|
||||
"retry_next_candidate_on_mismatch": True,
|
||||
# Opt-in exhaustive retry: budget retries PER SOURCE so every
|
||||
# source (Soulseek, then HiFi/Tidal/…) gets its own attempts
|
||||
# before the track gives up. Default off (single global cap).
|
||||
"retry_exhaustive": False,
|
||||
# Retries per search query per source in exhaustive mode. The
|
||||
# per-source budget is query_count × this value.
|
||||
"retries_per_query": 5,
|
||||
},
|
||||
"tidal_download": {
|
||||
"quality": "lossless", # Options: "low", "high", "lossless", "hires"
|
||||
|
|
@ -653,33 +605,13 @@ class ConfigManager:
|
|||
"metadata_enhancement": {
|
||||
"enabled": True,
|
||||
"embed_album_art": True,
|
||||
"post_process_order": ["musicbrainz", "deezer", "audiodb", "tidal", "qobuz", "lastfm", "genius"],
|
||||
# Ordered preferred cover-art sources (empty = use the
|
||||
# download's own art, i.e. today's behavior). Resolved + walked
|
||||
# with fallback by core/metadata/art_sources.py.
|
||||
"album_art_order": [],
|
||||
# Minimum cover-art resolution (shortest side, px). A preferred
|
||||
# source whose art is smaller is skipped so the next source is
|
||||
# tried — stops a low-res Cover Art Archive upload from winning.
|
||||
# 0 disables the size gate.
|
||||
"min_art_size": 1000,
|
||||
# When a track matches a SINGLE release, look up the parent ALBUM
|
||||
# that contains it and tag it as that album, so it groups with its
|
||||
# album-mates and gets the album cover (not the single's). Off by
|
||||
# default — it's an extra per-import metadata lookup.
|
||||
"single_to_album": False
|
||||
"post_process_order": ["musicbrainz", "deezer", "audiodb", "tidal", "qobuz", "lastfm", "genius"]
|
||||
},
|
||||
"musicbrainz": {
|
||||
"embed_tags": True
|
||||
},
|
||||
"playlist_sync": {
|
||||
"create_backup": True,
|
||||
# How a re-sync writes to the server playlist:
|
||||
# replace — delete + recreate (default; today's behavior)
|
||||
# reconcile — edit in place (add/remove delta), preserving the
|
||||
# playlist's custom image, description, and identity (#792)
|
||||
# append — only add new tracks, never remove
|
||||
"mode": "replace"
|
||||
"create_backup": True
|
||||
},
|
||||
"settings": {
|
||||
"audio_quality": "flac"
|
||||
|
|
@ -705,35 +637,12 @@ class ConfigManager:
|
|||
},
|
||||
"import": {
|
||||
"staging_path": "./Staging",
|
||||
# Master toggle for quality-filtering on import. On by default:
|
||||
# downloaded files that don't meet the quality profile are
|
||||
# quarantined instead of imported (same gate the download
|
||||
# pipeline uses). Off → import everything regardless of quality;
|
||||
# the library Quality Upgrade Scanner still flags them.
|
||||
"quality_filter_enabled": True,
|
||||
"replace_lower_quality": False,
|
||||
# Use the top Staging folder as the artist (Artist/Album layouts,
|
||||
# mixtapes). On by default to preserve the long-standing import
|
||||
# behaviour for existing users. Turn OFF if you stage a mixed pile
|
||||
# of songs under one container folder, otherwise that folder's name
|
||||
# overrides every metadata-identified artist (the "soulsync" case).
|
||||
"folder_artist_override": True
|
||||
"replace_lower_quality": False
|
||||
},
|
||||
"m3u_export": {
|
||||
"enabled": False,
|
||||
"entry_base_path": ""
|
||||
},
|
||||
"playlists": {
|
||||
# Where "Organize by playlist" materializes playlist folders.
|
||||
# MUST be a separate root from the music library so the media
|
||||
# server (and the maintenance jobs) never scan it — otherwise the
|
||||
# same track would show up twice. Mapped separately for Docker.
|
||||
"materialize_path": "./Playlists",
|
||||
# "symlink" (relative links, ~zero disk) or "copy" (real
|
||||
# duplicates for FAT/USB/DAPs that can't follow links). Symlink
|
||||
# auto-falls back to copy when the filesystem can't link.
|
||||
"materialize_mode": "symlink"
|
||||
},
|
||||
"youtube": {
|
||||
"cookies_browser": "", # "", "chrome", "firefox", "edge", "brave", "opera", "safari"
|
||||
"download_delay": 3, # seconds between sequential downloads
|
||||
|
|
@ -842,40 +751,7 @@ class ConfigManager:
|
|||
|
||||
return value
|
||||
|
||||
def redacted_config(self) -> Dict[str, Any]:
|
||||
"""Deep copy of the live config with every sensitive value masked.
|
||||
|
||||
Used for ``GET /api/settings`` so decrypted secrets never reach the
|
||||
browser (#832 follow-up). A *set* secret becomes ``REDACTED_SENTINEL``
|
||||
(the UI renders it as masked dots); an unset one stays empty so the UI
|
||||
can show "not configured". Dict-valued secrets (OAuth sessions) collapse
|
||||
to the sentinel too — the UI has no field for them anyway. The matching
|
||||
guard in ``set()`` turns a round-tripped sentinel back into a no-op.
|
||||
"""
|
||||
import copy
|
||||
data = copy.deepcopy(self.config_data)
|
||||
for path in self._SENSITIVE_PATHS:
|
||||
keys = path.split('.')
|
||||
parent = data
|
||||
for k in keys[:-1]:
|
||||
if isinstance(parent, dict) and k in parent:
|
||||
parent = parent[k]
|
||||
else:
|
||||
parent = None
|
||||
break
|
||||
if not isinstance(parent, dict):
|
||||
continue
|
||||
leaf = keys[-1]
|
||||
if leaf in parent and parent[leaf] not in (None, '', {}, [], 0, False):
|
||||
parent[leaf] = self.REDACTED_SENTINEL
|
||||
return data
|
||||
|
||||
def set(self, key: str, value: Any):
|
||||
# The UI round-trips REDACTED_SENTINEL for any secret the user didn't
|
||||
# touch — never let the mask overwrite the real value (#832 follow-up).
|
||||
if value == self.REDACTED_SENTINEL and key in self._SENSITIVE_PATHS:
|
||||
return
|
||||
|
||||
keys = key.split('.')
|
||||
config = self.config_data
|
||||
|
||||
|
|
@ -887,20 +763,6 @@ class ConfigManager:
|
|||
config[keys[-1]] = value
|
||||
self._save_config()
|
||||
|
||||
def resolve_secret(self, key: str, posted: Any) -> str:
|
||||
"""Resolve a secret value coming back from the settings UI.
|
||||
|
||||
The UI renders a saved-but-untouched secret as the REDACTED_SENTINEL (shown
|
||||
masked); empty or that sentinel means "use the stored value", while a real
|
||||
string is a genuine new secret. A connection-test endpoint should test the
|
||||
EFFECTIVE secret, not the mask — otherwise testing a saved-but-untouched
|
||||
token sends the sentinel and the source rejects it (#870)."""
|
||||
if isinstance(posted, str):
|
||||
posted = posted.strip()
|
||||
if not posted or posted == self.REDACTED_SENTINEL:
|
||||
return self.get(key, '') or ''
|
||||
return posted
|
||||
|
||||
def get_spotify_config(self) -> Dict[str, str]:
|
||||
return self.get('spotify', {})
|
||||
|
||||
|
|
|
|||
|
|
@ -282,9 +282,8 @@ class AcoustIDClient:
|
|||
|
||||
def test_api_key(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate the API key with a direct AcoustID lookup call. An invalid key
|
||||
is reported as invalid (error code 4); any other error means the key was
|
||||
accepted.
|
||||
Validate the API key by fingerprinting a real audio file and looking it up.
|
||||
Falls back to a direct API call if no audio files are available.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
|
|
@ -295,12 +294,24 @@ class AcoustIDClient:
|
|||
import requests
|
||||
|
||||
try:
|
||||
# Authoritative key check: a direct API lookup with a dummy
|
||||
# fingerprint. AcoustID validates the client key first, so an
|
||||
# invalid key returns error code 4 regardless of the fingerprint.
|
||||
# (The previous real-file path trusted "no exception = valid", but
|
||||
# fingerprint_and_lookup swallows the invalid-key error and returns
|
||||
# None — so it reported broken keys as valid. #756-adjacent.)
|
||||
# Try to find a real audio file to fingerprint for an end-to-end test
|
||||
test_file = self._find_test_audio_file()
|
||||
|
||||
if test_file and CHROMAPRINT_AVAILABLE:
|
||||
logger.info(f"Testing API key with real audio file: {test_file}")
|
||||
try:
|
||||
result = self.fingerprint_and_lookup(test_file)
|
||||
# If we get here without exception, the API key is valid
|
||||
# (invalid keys raise or return error before results)
|
||||
return True, "AcoustID API key is valid"
|
||||
except Exception as e:
|
||||
error_str = str(e).lower()
|
||||
if 'invalid' in error_str and 'api' in error_str:
|
||||
return False, "Invalid AcoustID API key - get one from https://acoustid.org/new-application"
|
||||
# Fingerprint/lookup failed for non-key reasons, fall through to direct test
|
||||
logger.warning(f"Real file test failed ({e}), trying direct API call")
|
||||
|
||||
# Fallback: direct API call with minimal fingerprint
|
||||
url = 'https://api.acoustid.org/v2/lookup'
|
||||
params = {
|
||||
'client': self.api_key,
|
||||
|
|
@ -315,6 +326,7 @@ class AcoustIDClient:
|
|||
if data.get('status') == 'error':
|
||||
error = data.get('error', {})
|
||||
error_code = error.get('code', 0)
|
||||
error_msg = error.get('message', 'Unknown error')
|
||||
|
||||
# Error code 4 is specifically "invalid API key"
|
||||
if error_code == 4:
|
||||
|
|
@ -334,33 +346,33 @@ class AcoustIDClient:
|
|||
logger.error(f"Error testing AcoustID API key: {e}")
|
||||
return False, f"Error: {str(e)}"
|
||||
|
||||
def lookup_with_status(self, audio_file: str) -> Dict[str, Any]:
|
||||
"""Fingerprint + AcoustID lookup returning a STRUCTURED result.
|
||||
def fingerprint_and_lookup(self, audio_file: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Generate fingerprint and look up recording in AcoustID.
|
||||
|
||||
Unlike fingerprint_and_lookup() (which collapses every outcome into
|
||||
dict-or-None), this distinguishes a genuine no-match from an actual
|
||||
error — an invalid API key, rate limit, missing chromaprint, or a
|
||||
fingerprint failure. That distinction is what lets the UI show "AcoustID
|
||||
Error" (something is broken — fix it) instead of a benign-looking
|
||||
"Skipped" that silently hides a dead key.
|
||||
This is the main method - combines fingerprinting and lookup in one call.
|
||||
|
||||
Returns dict with:
|
||||
'status': 'ok' | 'no_match' | 'error' | 'no_backend'
|
||||
| 'fingerprint_error' | 'unsupported' | 'unavailable'
|
||||
| 'not_found'
|
||||
'recordings': list (meaningful only for 'ok')
|
||||
'best_score': float
|
||||
'recording_mbids': list
|
||||
'error': human-readable detail for any non-'ok' status
|
||||
'invalid_key': bool (True when the API specifically rejected the key)
|
||||
Args:
|
||||
audio_file: Path to the audio file
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
'recordings': list of dicts with 'mbid', 'title', 'artist', 'score'
|
||||
'best_score': float (highest score across all results)
|
||||
'recording_mbids': list of unique MBIDs (for backward compat)
|
||||
Or None on error.
|
||||
"""
|
||||
if not ACOUSTID_AVAILABLE:
|
||||
return {'status': 'unavailable', 'recordings': [], 'error': 'pyacoustid library not installed'}
|
||||
logger.debug("Cannot lookup: pyacoustid not available")
|
||||
return None
|
||||
|
||||
if not self.api_key:
|
||||
return {'status': 'unavailable', 'recordings': [], 'error': 'No AcoustID API key configured'}
|
||||
logger.debug("Cannot lookup: no API key")
|
||||
return None
|
||||
|
||||
if not os.path.isfile(audio_file):
|
||||
logger.warning(f"Cannot lookup: file not found: {audio_file}")
|
||||
return {'status': 'not_found', 'recordings': [], 'error': f'File not found: {audio_file}'}
|
||||
return None
|
||||
|
||||
# Check channel count — chromaprint crashes (SIGABRT) on >2 channel files (e.g. 5.1 surround)
|
||||
try:
|
||||
|
|
@ -370,8 +382,7 @@ class AcoustIDClient:
|
|||
channels = getattr(mf.info, 'channels', 2)
|
||||
if channels and channels > 2:
|
||||
logger.warning(f"Skipping AcoustID: file has {channels} channels (surround audio): {audio_file}")
|
||||
return {'status': 'unsupported', 'recordings': [],
|
||||
'error': f'{channels}-channel (surround) audio not supported by chromaprint'}
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not check channel count, proceeding anyway: {e}")
|
||||
|
||||
|
|
@ -381,12 +392,17 @@ class AcoustIDClient:
|
|||
api_key_preview = f"{self.api_key[:8]}..." if self.api_key and len(self.api_key) > 8 else "NOT SET"
|
||||
logger.info(f"Fingerprinting and looking up: {audio_file} (API key: {api_key_preview})")
|
||||
|
||||
# Use match() which handles fingerprinting + lookup + parsing
|
||||
logger.debug("Running acoustid.match()...")
|
||||
recordings = []
|
||||
seen_mbids = set()
|
||||
best_score = 0.0
|
||||
|
||||
for result in acoustid.match(self.api_key, audio_file, parse=True):
|
||||
for result in acoustid.match(
|
||||
self.api_key,
|
||||
audio_file,
|
||||
parse=True
|
||||
):
|
||||
# match() with parse=True returns (score, recording_id, title, artist)
|
||||
if not isinstance(result, tuple) or len(result) < 2:
|
||||
logger.warning(f"Unexpected result format: {result}")
|
||||
|
|
@ -404,57 +420,45 @@ class AcoustIDClient:
|
|||
|
||||
if recording_id and recording_id not in seen_mbids:
|
||||
seen_mbids.add(recording_id)
|
||||
recordings.append({'mbid': recording_id, 'title': title, 'artist': artist, 'score': score})
|
||||
recordings.append({
|
||||
'mbid': recording_id,
|
||||
'title': title,
|
||||
'artist': artist,
|
||||
'score': score,
|
||||
})
|
||||
logger.debug(f"Found match: {title} by {artist} (MBID: {recording_id}, score: {score})")
|
||||
|
||||
if not recordings:
|
||||
logger.info(f"No AcoustID matches found for: {audio_file}")
|
||||
return {'status': 'no_match', 'recordings': [], 'best_score': best_score,
|
||||
'recording_mbids': [], 'error': 'Track not found in AcoustID database'}
|
||||
return None
|
||||
|
||||
logger.info(f"AcoustID found {len(recordings)} recording(s) (best score: {best_score:.2f})")
|
||||
return {'status': 'ok', 'recordings': recordings, 'best_score': best_score,
|
||||
'recording_mbids': list(seen_mbids)}
|
||||
return {
|
||||
'recordings': recordings,
|
||||
'best_score': best_score,
|
||||
'recording_mbids': list(seen_mbids),
|
||||
}
|
||||
|
||||
except acoustid.NoBackendError:
|
||||
logger.error("Chromaprint library not found and fpcalc not available")
|
||||
return {'status': 'no_backend', 'recordings': [],
|
||||
'error': 'Chromaprint/fpcalc not installed (install libchromaprint1)'}
|
||||
return None
|
||||
except acoustid.FingerprintGenerationError as e:
|
||||
logger.warning(f"Failed to fingerprint {audio_file}: {e}")
|
||||
return {'status': 'fingerprint_error', 'recordings': [], 'error': f'Could not fingerprint file: {e}'}
|
||||
return None
|
||||
except acoustid.WebServiceError as e:
|
||||
# Log more details about the API error
|
||||
api_key_preview = f"{self.api_key[:8]}..." if self.api_key and len(self.api_key) > 8 else "???"
|
||||
logger.warning(f"AcoustID API error (key: {api_key_preview}): {e}")
|
||||
# Check for common errors
|
||||
error_str = str(e).lower()
|
||||
# Old pyacoustid reports an invalid key as the bare "status: error"
|
||||
# (it drops the detail), so treat that as an invalid-key signal too.
|
||||
invalid = ('invalid' in error_str or 'unknown' in error_str or 'status: error' in error_str)
|
||||
if invalid:
|
||||
logger.error("AcoustID API key appears to be invalid — check your AcoustID settings")
|
||||
if 'invalid' in error_str or 'unknown' in error_str:
|
||||
logger.error("API key appears to be invalid - check your AcoustID settings")
|
||||
elif 'rate' in error_str or 'limit' in error_str:
|
||||
logger.warning("Rate limited by AcoustID — will retry later")
|
||||
return {'status': 'error', 'recordings': [], 'invalid_key': invalid,
|
||||
'error': f'AcoustID API error: {e}'}
|
||||
logger.warning("Rate limited by AcoustID - will retry later")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in AcoustID lookup: {e}", exc_info=True)
|
||||
return {'status': 'error', 'recordings': [], 'error': f'Unexpected error: {e}'}
|
||||
|
||||
def fingerprint_and_lookup(self, audio_file: str) -> Optional[Dict[str, Any]]:
|
||||
"""Legacy dict-or-None lookup. Returns the recordings dict on a confirmed
|
||||
match, else None. Kept for callers that only need "did we identify it"
|
||||
(library scanner, auto-import). Callers that must report WHY a lookup
|
||||
didn't match (verification badge, key test) should use
|
||||
``lookup_with_status`` so an error isn't mistaken for a no-match.
|
||||
"""
|
||||
res = self.lookup_with_status(audio_file)
|
||||
if res.get('status') == 'ok':
|
||||
return {
|
||||
'recordings': res['recordings'],
|
||||
'best_score': res.get('best_score', 0.0),
|
||||
'recording_mbids': res.get('recording_mbids', []),
|
||||
}
|
||||
return None
|
||||
return None
|
||||
|
||||
def refresh_config(self):
|
||||
"""Refresh cached config values (call after settings change)."""
|
||||
|
|
|
|||
|
|
@ -17,16 +17,14 @@ from utils.logging_config import get_logger
|
|||
from core.acoustid_client import AcoustIDClient
|
||||
from core.matching_engine import MusicMatchingEngine
|
||||
from core.matching.version_mismatch import is_acceptable_version_mismatch
|
||||
from core.matching.script_compat import is_cross_script_mismatch
|
||||
from core.musicbrainz_client import MusicBrainzClient
|
||||
|
||||
logger = get_logger("acoustid.verification")
|
||||
|
||||
# Thresholds — single definition lives in the shared core; re-exported here so
|
||||
# existing importers keep working and the values can't drift between paths.
|
||||
from core.matching.audio_verification import ( # noqa: E402
|
||||
MIN_ACOUSTID_SCORE, TITLE_MATCH_THRESHOLD, ARTIST_MATCH_THRESHOLD,
|
||||
)
|
||||
# Thresholds
|
||||
MIN_ACOUSTID_SCORE = 0.80 # Minimum AcoustID fingerprint score to trust
|
||||
TITLE_MATCH_THRESHOLD = 0.70 # Title similarity needed to consider a match
|
||||
ARTIST_MATCH_THRESHOLD = 0.60 # Artist similarity needed to consider a match
|
||||
|
||||
# Single matching-engine instance so version detection reuses the same patterns
|
||||
# used by the pre-download Soulseek matcher (remix / live / acoustic /
|
||||
|
|
@ -52,34 +50,170 @@ class VerificationResult(Enum):
|
|||
"""Possible outcomes of audio verification."""
|
||||
PASS = "pass" # Title/artist match - file is correct
|
||||
FAIL = "fail" # Title/artist mismatch - wrong file downloaded
|
||||
SKIP = "skip" # Genuinely couldn't verify (no match in DB) - continue normally
|
||||
SKIP = "skip" # Could not verify (error or unavailable) - continue normally
|
||||
DISABLED = "disabled" # Verification not enabled
|
||||
ERROR = "error" # Lookup errored (invalid key / rate limit / no backend) - continue, but flag it
|
||||
|
||||
|
||||
# normalize() + similarity() + the alias-aware comparison now live in the shared
|
||||
# decision core (core/matching/audio_verification.py) so import-time verification
|
||||
# and the library scan share ONE definition — the <>-strip fix, CJK handling and
|
||||
# thresholds can't drift apart again. Names kept (`_normalize` etc.) for existing
|
||||
# importers/tests.
|
||||
from core.matching.audio_verification import ( # noqa: E402
|
||||
normalize as _normalize,
|
||||
similarity as _similarity,
|
||||
_alias_aware_artist_sim,
|
||||
_find_best_title_artist_match as _core_find_best_title_artist_match,
|
||||
evaluate as _core_evaluate,
|
||||
Decision as _CoreDecision,
|
||||
)
|
||||
def _normalize(text: str) -> str:
|
||||
"""Normalize a string for comparison: lowercase, strip parentheticals, punctuation."""
|
||||
if not text:
|
||||
return ""
|
||||
s = text.lower().strip()
|
||||
# Remove ALL parenthetical suffixes — these are metadata annotations, not core title
|
||||
# Covers: (Live), (Remastered), (Parody of ...), (from "..." Soundtrack), (feat. ...), etc.
|
||||
s = re.sub(r'\s*\([^)]*\)', '', s)
|
||||
# Remove ALL square bracket suffixes: [Live], [Remastered], [Deluxe], etc.
|
||||
s = re.sub(r'\s*\[[^\]]*\]', '', s)
|
||||
# Remove trailing featuring info not in parentheses: "feat. ...", "ft. ...", "featuring ..."
|
||||
s = re.sub(r'\s+(?:feat\.?|ft\.?|featuring)\s+.*$', '', s, flags=re.IGNORECASE)
|
||||
# Remove dash-separated version tags: "- Vocal", "- Instrumental", "- Acoustic", etc.
|
||||
s = re.sub(r'\s*-\s*(?:vocal|instrumental|acoustic|live|remix|cover|clean|explicit|radio\s*edit|original\s*mix|extended\s*mix|club\s*mix)\s*$', '', s, flags=re.IGNORECASE)
|
||||
# Remove soundtrack/source subtitles: ' - From "..." Soundtrack', ' - from the film ...'
|
||||
s = re.sub(r'\s*-\s*from\s+.+$', '', s, flags=re.IGNORECASE)
|
||||
# Remove non-alphanumeric except spaces
|
||||
s = re.sub(r'[^\w\s]', '', s)
|
||||
# Collapse whitespace
|
||||
s = re.sub(r'\s+', ' ', s).strip()
|
||||
return s
|
||||
|
||||
|
||||
def _find_best_title_artist_match(recordings, expected_title, expected_artist,
|
||||
expected_artist_aliases=None):
|
||||
"""Back-compat wrapper around the shared core matcher (keeps the
|
||||
``expected_artist_aliases`` kwarg name for existing callers/tests)."""
|
||||
return _core_find_best_title_artist_match(
|
||||
recordings, expected_title, expected_artist, expected_artist_aliases,
|
||||
def _similarity(a: str, b: str) -> float:
|
||||
"""Calculate similarity between two strings (0.0-1.0) after normalization."""
|
||||
na = _normalize(a)
|
||||
nb = _normalize(b)
|
||||
if not na or not nb:
|
||||
return 0.0
|
||||
if na == nb:
|
||||
return 1.0
|
||||
return SequenceMatcher(None, na, nb).ratio()
|
||||
|
||||
|
||||
def _alias_aware_artist_sim(
|
||||
expected_artist: str,
|
||||
actual_artist: str,
|
||||
aliases: Optional[Any] = None,
|
||||
) -> float:
|
||||
"""Best artist-similarity across (expected, *aliases) vs actual.
|
||||
|
||||
Issue #442 — when expected and actual are in different scripts
|
||||
(e.g. `Hiroyuki Sawano` vs `澤野弘之`), raw `_similarity` scores
|
||||
near 0% even though MusicBrainz aliases bridge them. Routes
|
||||
through the pure helper so the verifier inherits one shared
|
||||
contract.
|
||||
|
||||
Returns the highest score across all candidates so existing
|
||||
threshold checks (>= ARTIST_MATCH_THRESHOLD) keep their
|
||||
semantics. When `aliases` is None or empty, behaves identically
|
||||
to the prior raw `_similarity(expected, actual)` call.
|
||||
|
||||
`aliases` accepts two shapes:
|
||||
|
||||
- **Iterable** (list/tuple/set of strings): used directly. Used
|
||||
by tests that already know the aliases.
|
||||
- **Callable**: invoked LAZILY only when direct similarity
|
||||
falls below the threshold. Lets the verifier pass a memoizing
|
||||
thunk that resolves aliases (DB / cache / live MB) only when
|
||||
needed. Verifications where the direct match already passes
|
||||
never trigger the lookup chain — no wasted DB query for the
|
||||
happy path.
|
||||
|
||||
Diagnostic logging: emits an INFO line whenever an alias rescues
|
||||
a comparison that direct similarity would have failed. Lets
|
||||
future bug reports trace which alias triggered which PASS
|
||||
decision (e.g. "this file passed because alias `澤野弘之` matched
|
||||
the file's artist tag").
|
||||
"""
|
||||
from core.matching.artist_aliases import artist_names_match
|
||||
|
||||
direct = _similarity(expected_artist, actual_artist)
|
||||
# Fast path — direct match already passes the threshold OR caller
|
||||
# supplied no aliases handle. Avoids any lookup work.
|
||||
if aliases is None:
|
||||
return direct
|
||||
if direct >= ARTIST_MATCH_THRESHOLD:
|
||||
return direct
|
||||
|
||||
# Resolve the iterable. Callable provider invoked NOW (lazily —
|
||||
# the caller can memoize the result across multiple invocations
|
||||
# within one verify_audio_file call).
|
||||
resolved = aliases() if callable(aliases) else aliases
|
||||
if not resolved:
|
||||
return direct
|
||||
|
||||
_matched, score = artist_names_match(
|
||||
expected_artist,
|
||||
actual_artist,
|
||||
aliases=resolved,
|
||||
threshold=ARTIST_MATCH_THRESHOLD,
|
||||
similarity=_similarity,
|
||||
)
|
||||
|
||||
# Diagnostic — alias rescued a comparison that direct would
|
||||
# have failed. Worth logging at INFO since it's a user-visible
|
||||
# decision (file PASS instead of FAIL). One line per rescue
|
||||
# within a single verify call.
|
||||
if score >= ARTIST_MATCH_THRESHOLD and direct < ARTIST_MATCH_THRESHOLD:
|
||||
from core.matching.artist_aliases import best_alias_match
|
||||
winner, _ = best_alias_match(
|
||||
expected_artist, actual_artist, resolved, similarity=_similarity,
|
||||
)
|
||||
logger.info(
|
||||
"Artist alias rescued comparison: expected=%r vs actual=%r "
|
||||
"(direct sim=%.2f, alias %r → score=%.2f)",
|
||||
expected_artist, actual_artist, direct, winner, score,
|
||||
)
|
||||
|
||||
return score
|
||||
|
||||
|
||||
def _find_best_title_artist_match(
|
||||
recordings: List[Dict[str, Any]],
|
||||
expected_title: str,
|
||||
expected_artist: str,
|
||||
expected_artist_aliases: Optional[Any] = None,
|
||||
) -> Tuple[Optional[Dict], float, float]:
|
||||
"""
|
||||
Find the AcoustID recording that best matches expected title/artist.
|
||||
|
||||
Issue #442 — `expected_artist_aliases` (when supplied) is the
|
||||
list of alternate spellings for `expected_artist` (Japanese
|
||||
kanji, Cyrillic, etc.). Accepts either:
|
||||
|
||||
- An iterable of alias strings (used eagerly), or
|
||||
- A callable returning the list (resolved lazily — only fires
|
||||
when at least one recording fails direct artist similarity).
|
||||
|
||||
Each recording's artist is scored against (expected, *aliases)
|
||||
and the best score wins. When the list is empty/omitted/None,
|
||||
behavior is identical to the prior raw similarity comparison.
|
||||
|
||||
Returns:
|
||||
(best_recording, title_similarity, artist_similarity)
|
||||
"""
|
||||
best_rec = None
|
||||
best_title_sim = 0.0
|
||||
best_artist_sim = 0.0
|
||||
best_combined = 0.0
|
||||
|
||||
for rec in recordings:
|
||||
title = rec.get('title') or ''
|
||||
artist = rec.get('artist') or ''
|
||||
|
||||
title_sim = _similarity(expected_title, title)
|
||||
artist_sim = _alias_aware_artist_sim(
|
||||
expected_artist, artist, expected_artist_aliases,
|
||||
)
|
||||
# Weight title higher since that's the primary identifier
|
||||
combined = (title_sim * 0.6) + (artist_sim * 0.4)
|
||||
|
||||
if combined > best_combined:
|
||||
best_combined = combined
|
||||
best_rec = rec
|
||||
best_title_sim = title_sim
|
||||
best_artist_sim = artist_sim
|
||||
|
||||
return best_rec, best_title_sim, best_artist_sim
|
||||
|
||||
|
||||
# Shared MusicBrainz client for enrichment lookups
|
||||
_mb_client = None
|
||||
|
|
@ -265,33 +399,18 @@ class AcoustIDVerification:
|
|||
logger.debug(f"AcoustID verification skipped: {reason}")
|
||||
return VerificationResult.SKIP, reason
|
||||
|
||||
# Step 2: Fingerprint and lookup in AcoustID (structured so an
|
||||
# actual error — invalid key / rate limit / no chromaprint — is
|
||||
# reported distinctly from a genuine no-match, instead of both
|
||||
# silently surfacing as "Skipped").
|
||||
# Step 2: Fingerprint and lookup in AcoustID
|
||||
logger.info(f"Fingerprinting and looking up: {audio_file_path}")
|
||||
lookup = self.acoustid_client.lookup_with_status(audio_file_path) or {}
|
||||
status = lookup.get('status')
|
||||
# Infer status by content when absent (a caller/stub that returned
|
||||
# just recordings): recordings => matched, none => no match.
|
||||
if status is None:
|
||||
status = 'ok' if lookup.get('recordings') else 'no_match'
|
||||
acoustid_result = self.acoustid_client.fingerprint_and_lookup(audio_file_path)
|
||||
|
||||
if status in ('error', 'no_backend', 'fingerprint_error', 'unavailable'):
|
||||
# Something is broken (not the track's fault) — never quarantine
|
||||
# on this; surface it so the user can fix it.
|
||||
return VerificationResult.ERROR, lookup.get('error', 'AcoustID lookup failed')
|
||||
if not acoustid_result:
|
||||
return VerificationResult.SKIP, "Track not found in AcoustID database"
|
||||
|
||||
if status != 'ok':
|
||||
# no_match / unsupported / not_found — genuinely could not verify.
|
||||
return VerificationResult.SKIP, lookup.get('error', 'No match in AcoustID database')
|
||||
|
||||
acoustid_result = lookup
|
||||
recordings = acoustid_result.get('recordings', [])
|
||||
best_score = acoustid_result.get('best_score', 0)
|
||||
|
||||
if not recordings:
|
||||
return VerificationResult.SKIP, "No match in AcoustID database"
|
||||
return VerificationResult.SKIP, "AcoustID returned no recordings"
|
||||
|
||||
logger.debug(
|
||||
f"AcoustID returned {len(recordings)} recording(s) "
|
||||
|
|
@ -330,32 +449,219 @@ class AcoustIDVerification:
|
|||
)
|
||||
return _alias_cache['value']
|
||||
|
||||
# Steps 4-5: delegate the PASS/SKIP/FAIL decision to the shared core
|
||||
# (core/matching/audio_verification.evaluate) so import verification
|
||||
# and the library scan apply identical logic.
|
||||
outcome = _core_evaluate(
|
||||
expected_track_name, expected_artist_name, recordings,
|
||||
fingerprint_score=best_score,
|
||||
aliases_provider=_aliases_provider,
|
||||
# Step 4: Find best title/artist match among AcoustID results
|
||||
best_rec, title_sim, artist_sim = _find_best_title_artist_match(
|
||||
recordings, expected_track_name, expected_artist_name,
|
||||
expected_artist_aliases=_aliases_provider,
|
||||
)
|
||||
|
||||
if not best_rec:
|
||||
return VerificationResult.SKIP, "No recordings with title/artist info"
|
||||
|
||||
matched_title = best_rec.get('title', '?')
|
||||
matched_artist = best_rec.get('artist', '?')
|
||||
|
||||
logger.info(
|
||||
"Best match: '%s' by '%s' (title_sim=%.2f, artist_sim=%.2f) -> %s",
|
||||
outcome.matched_title, outcome.matched_artist,
|
||||
outcome.title_sim, outcome.artist_sim, outcome.decision.value,
|
||||
f"Best match: '{matched_title}' by '{matched_artist}' "
|
||||
f"(title_sim={title_sim:.2f}, artist_sim={artist_sim:.2f})"
|
||||
)
|
||||
_decision_map = {
|
||||
_CoreDecision.PASS: VerificationResult.PASS,
|
||||
_CoreDecision.SKIP: VerificationResult.SKIP,
|
||||
_CoreDecision.FAIL: VerificationResult.FAIL,
|
||||
}
|
||||
result = _decision_map[outcome.decision]
|
||||
if result == VerificationResult.PASS:
|
||||
logger.info("AcoustID verification PASSED - %s", outcome.reason)
|
||||
elif result == VerificationResult.FAIL:
|
||||
logger.warning("AcoustID verification FAILED - %s", outcome.reason)
|
||||
else:
|
||||
logger.info("AcoustID verification SKIPPED - %s", outcome.reason)
|
||||
return result, outcome.reason
|
||||
|
||||
# Step 4b: Version-mismatch gate.
|
||||
#
|
||||
# The ``_normalize`` step deliberately strips parentheticals and
|
||||
# version tags ("(Instrumental)", "- Live", etc) so that legit
|
||||
# name variations don't fail the title-similarity comparison.
|
||||
# That same stripping made it impossible to tell a vocal track
|
||||
# apart from its instrumental: "In My Feelings" and "In My
|
||||
# Feelings (Instrumental)" both normalize to "in my feelings",
|
||||
# the title sim ends up 1.0, and the file passes verification
|
||||
# even though it's the wrong cut.
|
||||
#
|
||||
# Detect the version on each side BEFORE normalization runs.
|
||||
# If the expected track and the AcoustID-matched recording
|
||||
# disagree on version (one is original, the other is
|
||||
# instrumental / live / remix / acoustic / etc), reject — the
|
||||
# fingerprint identified a real song but it's not the one the
|
||||
# caller asked for.
|
||||
expected_version = _detect_title_version(expected_track_name)
|
||||
matched_version = _detect_title_version(matched_title)
|
||||
if expected_version != matched_version:
|
||||
# Issue #607 (AfonsoG6): MusicBrainz often stores live
|
||||
# recordings with bare titles ("Clarity") while the
|
||||
# release entry carries the venue annotation ("Clarity
|
||||
# (Live at Blossom Music Center, ...)"). The fingerprint
|
||||
# correctly identifies the LIVE recording; only the
|
||||
# title text is bare. Helper accepts the one-sided bare
|
||||
# case when fingerprint + bare-title + artist all agree.
|
||||
# Two-sided version mismatches (live vs remix etc) stay
|
||||
# strict — those are genuinely different recordings.
|
||||
if is_acceptable_version_mismatch(
|
||||
expected_version, matched_version,
|
||||
fingerprint_score=best_score,
|
||||
title_similarity=title_sim,
|
||||
artist_similarity=artist_sim,
|
||||
):
|
||||
logger.info(
|
||||
f"AcoustID version annotation differs (expected={expected_version}, "
|
||||
f"matched={matched_version}) but fingerprint+title+artist all match — "
|
||||
f"accepting (likely MB metadata gap on a live/version-annotated recording)"
|
||||
)
|
||||
else:
|
||||
msg = (
|
||||
f"Version mismatch: expected '{expected_track_name}' ({expected_version}) "
|
||||
f"but file is '{matched_title}' ({matched_version})"
|
||||
)
|
||||
logger.warning(f"AcoustID verification FAILED (version mismatch) - {msg}")
|
||||
return VerificationResult.FAIL, msg
|
||||
|
||||
# Step 5: Decide pass/fail based on similarity
|
||||
if title_sim >= TITLE_MATCH_THRESHOLD and artist_sim >= ARTIST_MATCH_THRESHOLD:
|
||||
msg = (
|
||||
f"Audio verified: '{matched_title}' by '{matched_artist}' "
|
||||
f"matches expected '{expected_track_name}' by '{expected_artist_name}' "
|
||||
f"(title={title_sim:.0%}, artist={artist_sim:.0%})"
|
||||
)
|
||||
logger.info(f"AcoustID verification PASSED - {msg}")
|
||||
return VerificationResult.PASS, msg
|
||||
|
||||
# Title matches but artist doesn't — could be a cover/collab OR a
|
||||
# genuinely different track with the same name. Distinguish the
|
||||
# two by checking whether the expected artist appears anywhere in
|
||||
# AcoustID's returned recordings.
|
||||
if title_sim >= TITLE_MATCH_THRESHOLD and artist_sim < ARTIST_MATCH_THRESHOLD:
|
||||
# First: if the expected artist is present in ANY recording's
|
||||
# metadata for this fingerprint, it's likely the right track
|
||||
# (AcoustID's "best" match just picked the wrong variant).
|
||||
for rec in recordings:
|
||||
rec_artist = rec.get('artist', '')
|
||||
if _alias_aware_artist_sim(
|
||||
expected_artist_name, rec_artist, _aliases_provider,
|
||||
) >= ARTIST_MATCH_THRESHOLD:
|
||||
msg = (
|
||||
f"Audio verified: found '{expected_track_name}' by '{expected_artist_name}' "
|
||||
f"in AcoustID results"
|
||||
)
|
||||
logger.info(f"AcoustID verification PASSED (secondary match) - {msg}")
|
||||
return VerificationResult.PASS, msg
|
||||
|
||||
# Expected artist wasn't found anywhere. Decide between:
|
||||
# - FAIL: clear mismatch, e.g. "Tom Walker" (sim ~0.2) when
|
||||
# expecting "Maduk" — different song with same name
|
||||
# - SKIP: ambiguous, e.g. collab / alt credit / formatting
|
||||
# difference (sim 0.3-0.6)
|
||||
#
|
||||
# The 0.3 cutoff catches hard mismatches while preserving the
|
||||
# benefit of the doubt for borderline artist formatting.
|
||||
CLEAR_MISMATCH_THRESHOLD = 0.3
|
||||
if artist_sim < CLEAR_MISMATCH_THRESHOLD:
|
||||
msg = (
|
||||
f"Audio mismatch: file identified as '{matched_title}' by '{matched_artist}', "
|
||||
f"expected '{expected_track_name}' by '{expected_artist_name}' "
|
||||
f"(title={title_sim:.0%}, artist={artist_sim:.0%}) — "
|
||||
f"expected artist not found in any AcoustID recording"
|
||||
)
|
||||
logger.warning(f"AcoustID verification FAILED (clear artist mismatch) - {msg}")
|
||||
return VerificationResult.FAIL, msg
|
||||
|
||||
msg = (
|
||||
f"Title matches but artist unclear: "
|
||||
f"AcoustID='{matched_title}' by '{matched_artist}', "
|
||||
f"expected '{expected_track_name}' by '{expected_artist_name}' "
|
||||
f"(artist_sim={artist_sim:.0%} — ambiguous, could be cover/collab)"
|
||||
)
|
||||
logger.info(f"AcoustID verification SKIPPED - {msg}")
|
||||
return VerificationResult.SKIP, msg
|
||||
|
||||
# Title doesn't match — check ALL recordings for any title/artist match
|
||||
# (the best combined match might not be the right one if there are many results)
|
||||
# Skip recordings whose version (instrumental/live/etc) disagrees with
|
||||
# what the caller asked for — the version mismatch above checked
|
||||
# only the best recording, but a wrong-version variant could still
|
||||
# win this fallback scan if its bare title matched.
|
||||
for rec in recordings:
|
||||
t = rec.get('title') or ''
|
||||
a = rec.get('artist') or ''
|
||||
if _detect_title_version(t) != expected_version:
|
||||
continue
|
||||
if (_similarity(expected_track_name, t) >= TITLE_MATCH_THRESHOLD and
|
||||
_alias_aware_artist_sim(
|
||||
expected_artist_name, a, _aliases_provider,
|
||||
) >= ARTIST_MATCH_THRESHOLD):
|
||||
msg = (
|
||||
f"Audio verified: found '{t}' by '{a}' in AcoustID results "
|
||||
f"matching expected '{expected_track_name}' by '{expected_artist_name}'"
|
||||
)
|
||||
logger.info(f"AcoustID verification PASSED (scan match) - {msg}")
|
||||
return VerificationResult.PASS, msg
|
||||
|
||||
# No match found — but if fingerprint score is very high (≥0.95)
|
||||
# AND we have evidence the mismatch is a language/script case
|
||||
# (rather than two genuinely different songs by the same artist),
|
||||
# skip rather than quarantine a correct file. Two routes:
|
||||
#
|
||||
# (a) Either side of the comparison contains non-ASCII characters
|
||||
# — strong signal of transliteration / kanji↔roman cases.
|
||||
# Artist must still be a strong match to use this path.
|
||||
# (b) Both title AND artist similarity are very high (the song
|
||||
# is recognizably the same with minor punctuation / casing
|
||||
# differences that fell below the strict match thresholds).
|
||||
#
|
||||
# The OLD logic was ``title_sim >= 0.55 OR artist_sim >= match``.
|
||||
# That fired for English-vs-English songs by the same artist that
|
||||
# share NO actual content — e.g. "R.O.T.C (Interlude)" by
|
||||
# Kendrick Lamar getting accepted as "Rich (Interlude)" by
|
||||
# Kendrick Lamar because the artist matched perfectly and
|
||||
# "interlude" was shared in both titles. Reported by user when
|
||||
# downloading Mr. Morale: three tracks (Rich Interlude, Savior
|
||||
# Interlude, Savior) all received the wrong R.O.T.C audio file
|
||||
# because of this leak.
|
||||
# Use the BEST matching recording's strings here (not
|
||||
# `recordings[0]`) so the failure message reports the same
|
||||
# candidate the title/artist similarity scores came from.
|
||||
# Issue #607 (AfonsoG6) example 1: the prior code mixed
|
||||
# `recordings[0]`'s strings (which can be empty) with
|
||||
# `best_rec`'s scores, producing nonsense reasons like
|
||||
# "file identified as '' by '' (artist=100%)" when a later
|
||||
# recording in the list scored well on artist.
|
||||
display_title = matched_title or '?'
|
||||
display_artist = matched_artist or '?'
|
||||
has_non_ascii = (
|
||||
any(ord(c) > 127 for c in (expected_track_name or ''))
|
||||
or any(ord(c) > 127 for c in display_title)
|
||||
)
|
||||
language_script_skip = (
|
||||
best_score >= 0.95
|
||||
and has_non_ascii
|
||||
and artist_sim >= ARTIST_MATCH_THRESHOLD
|
||||
)
|
||||
high_confidence_strong_match_skip = (
|
||||
best_score >= 0.95
|
||||
and title_sim >= 0.80
|
||||
and artist_sim >= ARTIST_MATCH_THRESHOLD
|
||||
)
|
||||
if language_script_skip or high_confidence_strong_match_skip:
|
||||
reason = (
|
||||
"likely same song in different language/script"
|
||||
if language_script_skip
|
||||
else "title/artist match within tolerance"
|
||||
)
|
||||
msg = (
|
||||
f"Title/artist mismatch but fingerprint confidence very high ({best_score:.2f}): "
|
||||
f"AcoustID='{display_title}' by '{display_artist}', "
|
||||
f"expected '{expected_track_name}' by '{expected_artist_name}' — "
|
||||
f"{reason}"
|
||||
)
|
||||
logger.info(f"AcoustID verification SKIPPED (high confidence) - {msg}")
|
||||
return VerificationResult.SKIP, msg
|
||||
|
||||
# Low fingerprint score + no metadata match — file is likely wrong.
|
||||
msg = (
|
||||
f"Audio mismatch: file identified as '{display_title}' by '{display_artist}', "
|
||||
f"expected '{expected_track_name}' by '{expected_artist_name}' "
|
||||
f"(title={title_sim:.0%}, artist={artist_sim:.0%})"
|
||||
)
|
||||
logger.warning(f"AcoustID verification FAILED - {msg}")
|
||||
return VerificationResult.FAIL, msg
|
||||
|
||||
except Exception as e:
|
||||
# Any unexpected error -> SKIP (fail open)
|
||||
|
|
|
|||
|
|
@ -70,16 +70,7 @@ _meta_cache_lock = threading.Lock()
|
|||
|
||||
|
||||
class AmazonClientError(RuntimeError):
|
||||
"""Raised on unrecoverable T2Tunes API errors.
|
||||
|
||||
Carries the HTTP ``status_code`` when the failure was an HTTP error, so
|
||||
callers (the worker's outage detection) can tell a source outage (5xx) from
|
||||
a per-item miss without parsing the message.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, status_code=None):
|
||||
super().__init__(*args)
|
||||
self.status_code = status_code
|
||||
"""Raised on unrecoverable T2Tunes API errors."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -712,8 +703,7 @@ class AmazonClient:
|
|||
)
|
||||
continue
|
||||
raise AmazonClientError(
|
||||
f"HTTP {exc.response.status_code} for {url} — body: {body!r}",
|
||||
status_code=exc.response.status_code,
|
||||
f"HTTP {exc.response.status_code} for {url} — body: {body!r}"
|
||||
) from exc
|
||||
except requests.RequestException as exc:
|
||||
raise AmazonClientError(f"Request failed for {url}: {exc}") from exc
|
||||
|
|
|
|||
|
|
@ -32,8 +32,6 @@ from config.settings import config_manager
|
|||
from core.amazon_client import AmazonClient, AmazonClientError
|
||||
from core.download_plugins.base import DownloadSourcePlugin
|
||||
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
|
||||
from core.quality.model import AudioQuality
|
||||
from core.quality.source_map import quality_tier_for_source
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("amazon_download_client")
|
||||
|
|
@ -78,12 +76,9 @@ class AmazonDownloadClient(DownloadSourcePlugin):
|
|||
if download_path is None:
|
||||
download_path = config_manager.get("soulseek.download_path", "./downloads")
|
||||
self.download_path = Path(download_path)
|
||||
try:
|
||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
logger.warning(f"Could not verify download path {self.download_path}: {e}")
|
||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._quality = quality_tier_for_source("amazon", default="flac")
|
||||
self._quality = config_manager.get("amazon_download.quality", "flac")
|
||||
self._allow_fallback = config_manager.get("amazon_download.allow_fallback", True)
|
||||
|
||||
self._client = AmazonClient(preferred_codec=self._quality)
|
||||
|
|
@ -138,17 +133,11 @@ class AmazonDownloadClient(DownloadSourcePlugin):
|
|||
album_map: Dict[str, AlbumResult] = {}
|
||||
album_order: List[str] = []
|
||||
preferred = self._client.preferred_codec
|
||||
# Search results only carry the codec (real sample_rate arrives at
|
||||
# stream time). Claim the format honestly — FLAC for the lossless
|
||||
# codec, lossy otherwise — so audio_quality derives a real format
|
||||
# instead of the display label ("Lossless"), and the post-download
|
||||
# probe pins the actual sample_rate/bit_depth.
|
||||
amazon_q = AudioQuality(format='flac' if _codec_key(preferred) == 'flac' else 'aac')
|
||||
|
||||
for item in items:
|
||||
quality = _quality_label(preferred)
|
||||
if item.is_track:
|
||||
tr = TrackResult(
|
||||
track_results.append(TrackResult(
|
||||
username="amazon",
|
||||
filename=f"{item.asin}||{item.artist_name} - {item.title}",
|
||||
size=0,
|
||||
|
|
@ -166,9 +155,7 @@ class AmazonDownloadClient(DownloadSourcePlugin):
|
|||
"album_asin": item.album_asin,
|
||||
"isrc": item.isrc,
|
||||
},
|
||||
)
|
||||
tr.set_quality(amazon_q)
|
||||
track_results.append(tr)
|
||||
))
|
||||
elif item.is_album:
|
||||
album_asin = item.album_asin or item.asin
|
||||
if album_asin not in album_map:
|
||||
|
|
@ -186,7 +173,6 @@ class AmazonDownloadClient(DownloadSourcePlugin):
|
|||
title=item.title,
|
||||
album=item.album_name,
|
||||
)
|
||||
placeholder.set_quality(amazon_q)
|
||||
album_map[album_asin] = AlbumResult(
|
||||
username="amazon",
|
||||
album_path=album_asin,
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
"""Amazon enrichment outage detection + back-off — pure, importable, testable.
|
||||
|
||||
The Amazon worker enriches via a public T2Tunes proxy instance. When that
|
||||
instance is down (HTTP 5xx, "Amazon Music API is not initialized", or an
|
||||
unreachable host), the worker must NOT treat every album as an individual
|
||||
failure: doing so floods the logs with an error per item, churns network + DB
|
||||
continuously, and permanently marks the whole library ``error`` (which the
|
||||
retry tiers never re-attempt) for what is really a transient outage.
|
||||
|
||||
Instead it recognizes "the whole source is down", leaves the item untouched so
|
||||
it's retried once the instance recovers, and backs off hard. These two pure
|
||||
helpers carry that logic so it can be unit-tested without the worker, the DB,
|
||||
or the network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# HTTP statuses that mean "the source/proxy is unhealthy", not "no match".
|
||||
_OUTAGE_STATUS = {500, 502, 503, 504}
|
||||
|
||||
# Substrings (lower-cased) in an error message that indicate a source outage
|
||||
# rather than a per-item miss: proxy not ready, gateway errors, the host being
|
||||
# unreachable, or an error page returned instead of JSON.
|
||||
_OUTAGE_PHRASES = (
|
||||
"not initialized", "not configured", "service unavailable",
|
||||
"bad gateway", "gateway time", "request failed", "response not json",
|
||||
"max retries", "connection", "timed out", "temporarily unavailable",
|
||||
)
|
||||
|
||||
# Back-off schedule while the source is down.
|
||||
_NORMAL_DELAY = 2 # seconds between items when healthy
|
||||
_OUTAGE_BASE = 30 # first back-off step
|
||||
_OUTAGE_CAP = 1800 # 30 minutes max
|
||||
|
||||
|
||||
def is_source_outage(exc: Exception) -> bool:
|
||||
"""True when ``exc`` indicates the Amazon source/proxy is down (transient,
|
||||
whole-source), as opposed to a normal per-item error.
|
||||
|
||||
Robust to how the error is surfaced: an explicit ``status_code`` attribute,
|
||||
an ``HTTP <code>`` prefix in the message, or an outage phrase (covers
|
||||
connection failures and non-JSON error pages that carry no status code)."""
|
||||
code = getattr(exc, "status_code", None)
|
||||
if isinstance(code, int) and code in _OUTAGE_STATUS:
|
||||
return True
|
||||
msg = str(exc).lower()
|
||||
m = re.search(r"http\s+(\d{3})", msg)
|
||||
if m and int(m.group(1)) in _OUTAGE_STATUS:
|
||||
return True
|
||||
return any(p in msg for p in _OUTAGE_PHRASES)
|
||||
|
||||
|
||||
def next_poll_delay_seconds(outage_streak: int) -> int:
|
||||
"""Seconds to wait before the next item. Normal cadence when healthy;
|
||||
escalating back-off (30s, 60s, 120s, … capped at 30 min) the longer the
|
||||
source has been down, so a dead instance can't flood logs/CPU/DB."""
|
||||
if outage_streak <= 0:
|
||||
return _NORMAL_DELAY
|
||||
return min(_OUTAGE_BASE * (2 ** min(outage_streak - 1, 6)), _OUTAGE_CAP)
|
||||
|
|
@ -9,7 +9,6 @@ from database.music_database import MusicDatabase
|
|||
from core.amazon_client import AmazonClient
|
||||
from core.worker_utils import interruptible_sleep, set_album_api_track_count
|
||||
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||
from core.amazon_outage import is_source_outage, next_poll_delay_seconds
|
||||
|
||||
logger = get_logger("amazon_worker")
|
||||
|
||||
|
|
@ -40,11 +39,6 @@ class AmazonWorker:
|
|||
self.retry_days = 30
|
||||
self.name_similarity_threshold = 0.80
|
||||
|
||||
# Source-outage circuit breaker: counts consecutive whole-source
|
||||
# failures (proxy down / "not initialized" / unreachable) so the loop
|
||||
# backs off instead of grinding the whole library item-by-item.
|
||||
self._outage_streak = 0
|
||||
|
||||
logger.info("Amazon background worker initialized")
|
||||
|
||||
def _ensure_amazon_schema(self, cursor) -> None:
|
||||
|
|
@ -157,9 +151,7 @@ class AmazonWorker:
|
|||
continue
|
||||
|
||||
self._process_item(item)
|
||||
# Normal 2s cadence when healthy; escalating back-off (up to
|
||||
# 30 min) while the source is in an outage streak.
|
||||
interruptible_sleep(self._stop_event, next_poll_delay_seconds(self._outage_streak))
|
||||
interruptible_sleep(self._stop_event, 2)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in worker loop: {e}")
|
||||
|
|
@ -174,16 +166,6 @@ class AmazonWorker:
|
|||
cursor = conn.cursor()
|
||||
self._ensure_amazon_schema(cursor)
|
||||
|
||||
# Pinned-group override (Manage Enrichment Workers): process one
|
||||
# entity type first, then fall through to the normal chain. Unset or
|
||||
# exhausted ⇒ default artist→album→track order, unchanged.
|
||||
from core.worker_utils import read_enrichment_priority, priority_pending_item
|
||||
_prio = read_enrichment_priority('amazon')
|
||||
if _prio:
|
||||
_pi = priority_pending_item(cursor, 'amazon', _prio)
|
||||
if _pi:
|
||||
return _pi
|
||||
|
||||
# Priority 1: Unattempted artists
|
||||
cursor.execute("""
|
||||
SELECT id, name FROM artists
|
||||
|
|
@ -293,32 +275,7 @@ class AmazonWorker:
|
|||
elif item_type == 'track':
|
||||
self._process_track(item_id, item_name, item.get('artist', ''), item)
|
||||
|
||||
# The source answered (match or not_found) — clear any outage streak.
|
||||
if self._outage_streak:
|
||||
logger.info("Amazon source recovered after %d outage(s), resuming",
|
||||
self._outage_streak)
|
||||
self._outage_streak = 0
|
||||
|
||||
except Exception as e:
|
||||
if is_source_outage(e):
|
||||
# The whole source is down (proxy 5xx / "not initialized" /
|
||||
# unreachable). Do NOT mark the item 'error' — that would burn
|
||||
# the entire library to a state the retry tiers never re-attempt
|
||||
# for a transient outage. Leave it untouched so it's retried once
|
||||
# the instance recovers, and let the loop back off. Log once per
|
||||
# streak to avoid flooding.
|
||||
self._outage_streak += 1
|
||||
if self._outage_streak == 1:
|
||||
logger.warning("Amazon source unavailable — pausing enrichment "
|
||||
"until it recovers: %s", e)
|
||||
else:
|
||||
logger.debug("Amazon source still unavailable (streak=%d): %s",
|
||||
self._outage_streak, e)
|
||||
return
|
||||
# A non-outage error means the source actually answered (e.g. a
|
||||
# 404/parse error on a real response), so the outage is over —
|
||||
# clear the streak and handle this as a normal per-item error.
|
||||
self._outage_streak = 0
|
||||
logger.error(f"Error processing {item['type']} #{item['id']}: {e}")
|
||||
self.stats['errors'] += 1
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -53,11 +53,7 @@ def find_library_artist_for_source(
|
|||
|
||||
Lookup order:
|
||||
1. Direct match on the source-specific ID column (server-agnostic — any
|
||||
library record with the right external ID is a hit). If that id is
|
||||
stamped on MORE than one library artist, the mapping is corrupt /
|
||||
ambiguous (e.g. an enrichment bug wrote one Deezer id onto several
|
||||
artists) — we refuse to guess and fall through, so the caller can
|
||||
show the source artist directly instead of an arbitrary wrong one.
|
||||
library record with the right external ID is a hit).
|
||||
2. Case-insensitive name match within ``active_server`` (defaults to the
|
||||
active media server when not provided), so we don't jump the user
|
||||
across server contexts on a name collision.
|
||||
|
|
@ -71,23 +67,13 @@ def find_library_artist_for_source(
|
|||
try:
|
||||
with database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
# LIMIT 2 so we can tell a unique match from an ambiguous one.
|
||||
cursor.execute(
|
||||
f"SELECT id FROM artists WHERE {column} = ? LIMIT 2",
|
||||
f"SELECT id, name FROM artists WHERE {column} = ? LIMIT 1",
|
||||
(str(source_artist_id),),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
if len(rows) == 1:
|
||||
return rows[0][0]
|
||||
if len(rows) > 1:
|
||||
# Same source id on multiple artists — corrupt mapping. Don't
|
||||
# upgrade on the id; fall through to the name match (and, if
|
||||
# that misses, let the caller render the source artist).
|
||||
logger.warning(
|
||||
f"Source id {source}:{source_artist_id} maps to "
|
||||
f"{len(rows)}+ library artists — ambiguous, skipping "
|
||||
f"id-based library upgrade"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
|
||||
if artist_name and active_server:
|
||||
cursor.execute(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from datetime import datetime, timedelta
|
|||
from utils.logging_config import get_logger
|
||||
from database.music_database import MusicDatabase
|
||||
from core.audiodb_client import AudioDBClient
|
||||
from core.worker_utils import accept_artist_match, interruptible_sleep
|
||||
from core.worker_utils import interruptible_sleep
|
||||
|
||||
logger = get_logger("audiodb_worker")
|
||||
|
||||
|
|
@ -162,16 +162,6 @@ class AudioDBWorker:
|
|||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Pinned-group override (Manage Enrichment Workers): process one
|
||||
# entity type first, then fall through to the normal chain. Unset or
|
||||
# exhausted ⇒ default artist→album→track order, unchanged.
|
||||
from core.worker_utils import read_enrichment_priority, priority_pending_item
|
||||
_prio = read_enrichment_priority('audiodb')
|
||||
if _prio:
|
||||
_pi = priority_pending_item(cursor, 'audiodb', _prio)
|
||||
if _pi:
|
||||
return _pi
|
||||
|
||||
# Priority 1: Unattempted artists
|
||||
cursor.execute("""
|
||||
SELECT id, name
|
||||
|
|
@ -273,13 +263,8 @@ class AudioDBWorker:
|
|||
|
||||
def _verify_artist_id(self, item: Dict[str, Any], result: Dict[str, Any]) -> bool:
|
||||
"""Verify that the result's artist ID matches the parent artist's stored AudioDB ID.
|
||||
|
||||
If mismatched, the album/track search is more specific (uses artist+title),
|
||||
so we trust it and correct the parent artist's audiodb_id — BUT only when
|
||||
the result's artist *name* matches our parent artist. Without that guard,
|
||||
a collaboration/compilation (a track our library credits to one artist
|
||||
that lives on another artist's album) would stamp the wrong AudioDB id
|
||||
onto our artist. See the Deezer fix for the full write-up."""
|
||||
so we trust it and correct the parent artist's audiodb_id."""
|
||||
parent_audiodb_id = item.get('artist_audiodb_id')
|
||||
if not parent_audiodb_id:
|
||||
return True
|
||||
|
|
@ -289,18 +274,6 @@ class AudioDBWorker:
|
|||
return True
|
||||
|
||||
if str(result_artist_id) != str(parent_audiodb_id):
|
||||
parent_name = item.get('artist') or ''
|
||||
result_artist_name = result.get('strArtist') or ''
|
||||
if (result_artist_name and parent_name
|
||||
and not self._name_matches(parent_name, result_artist_name)):
|
||||
logger.info(
|
||||
f"Skipping artist-ID correction from {item['type']} "
|
||||
f"'{item['name']}': result artist '{result_artist_name}' "
|
||||
f"≠ parent '{parent_name}' (collab/compilation, not a "
|
||||
f"correction)"
|
||||
)
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
f"Artist ID correction from {item['type']} '{item['name']}': "
|
||||
f"updating parent artist AudioDB ID from {parent_audiodb_id} to {result_artist_id}"
|
||||
|
|
@ -426,18 +399,14 @@ class AudioDBWorker:
|
|||
result = self.client.search_artist(item_name)
|
||||
if result:
|
||||
result_name = result.get('strArtist', '')
|
||||
ok, reason = accept_artist_match(
|
||||
self.db, 'audiodb_id', result.get('idArtist'), item_id,
|
||||
item_name, result_name,
|
||||
)
|
||||
if ok:
|
||||
if self._name_matches(item_name, result_name):
|
||||
self._update_artist(item_id, result)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched artist '{item_name}' -> AudioDB ID: {result.get('idArtist')}")
|
||||
else:
|
||||
self._mark_status('artist', item_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Artist '{item_name}' not matched: {reason}")
|
||||
logger.debug(f"Name mismatch for artist '{item_name}' (got '{result_name}')")
|
||||
else:
|
||||
self._mark_status('artist', item_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ from datetime import datetime
|
|||
from difflib import SequenceMatcher
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from core.imports.folder_artist import resolve_folder_artist
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("auto_import")
|
||||
|
|
@ -660,13 +659,8 @@ class AutoImportWorker:
|
|||
auto_process = self._config_manager.get('auto_import.auto_process', True)
|
||||
|
||||
try:
|
||||
# Phase 3: Identify.
|
||||
# Re-identify (#889): if the user designated this exact file's release in
|
||||
# the Re-identify modal, a hint short-circuits the guessing — we match
|
||||
# straight against the chosen album. No hint → byte-identical to before.
|
||||
rematch_hint, identification = self._resolve_rematch_hint(candidate)
|
||||
if identification is None:
|
||||
identification = self._identify_folder(candidate)
|
||||
# Phase 3: Identify
|
||||
identification = self._identify_folder(candidate)
|
||||
if not identification:
|
||||
self._record_result(candidate, 'needs_identification', 0.0,
|
||||
error_message='Could not identify album from tags, folder name, or fingerprint')
|
||||
|
|
@ -695,10 +689,7 @@ class AutoImportWorker:
|
|||
high_conf_matches = [m for m in match_result.get('matches', []) if m['confidence'] >= 0.8]
|
||||
has_strong_individual_matches = len(high_conf_matches) > 0
|
||||
|
||||
# A re-identify is an explicit user choice — let it auto-process like a
|
||||
# strong match (still gated on the global auto_process preference).
|
||||
if (confidence >= threshold or has_strong_individual_matches
|
||||
or rematch_hint is not None) and auto_process:
|
||||
if (confidence >= threshold or has_strong_individual_matches) and auto_process:
|
||||
# Phase 5: Auto-process — insert an in-progress row
|
||||
# so the UI sees the import the moment it starts,
|
||||
# then update it with the final status when done.
|
||||
|
|
@ -717,13 +708,6 @@ class AutoImportWorker:
|
|||
confidence = max(confidence, effective_conf)
|
||||
if success:
|
||||
self._bump_stat('auto_processed')
|
||||
# Re-identify (#889): only NOW that the new home exists do we
|
||||
# consume the hint and (if replace was chosen) delete the old
|
||||
# row + file — so a failed import never loses the original. Pass
|
||||
# the landing paths so we never delete a file the re-import landed
|
||||
# at the SAME place (picking the release it's already in).
|
||||
if rematch_hint is not None:
|
||||
self._finalize_rematch_hint(rematch_hint, getattr(candidate, '_reid_final_paths', None))
|
||||
else:
|
||||
self._bump_stat('failed')
|
||||
|
||||
|
|
@ -1018,75 +1002,6 @@ class AutoImportWorker:
|
|||
except Exception:
|
||||
return False
|
||||
|
||||
# ── Re-identify hints (#889) ──
|
||||
|
||||
def _resolve_rematch_hint(self, candidate: 'FolderCandidate'):
|
||||
"""If this staged file carries a user-designated re-identify hint, return
|
||||
``(hint, identification)`` so matching skips the guessing tiers; otherwise
|
||||
``(None, None)`` and the caller falls back to normal identification.
|
||||
|
||||
Fail-safe: ANY error (no table, DB hiccup) returns ``(None, None)`` so a
|
||||
re-identify problem can never break ordinary auto-import. Only single-file
|
||||
candidates are eligible — a re-identify always stages exactly one track."""
|
||||
try:
|
||||
files = candidate.audio_files or []
|
||||
if len(files) != 1:
|
||||
return None, None
|
||||
from core.imports.rematch_hints import (
|
||||
build_identification_from_hint,
|
||||
find_hint_for_file,
|
||||
quick_file_signature,
|
||||
)
|
||||
file_path = files[0]
|
||||
sig = quick_file_signature(file_path)
|
||||
conn = self.database._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
hint = find_hint_for_file(cursor, file_path, sig)
|
||||
finally:
|
||||
conn.close()
|
||||
if hint is None:
|
||||
return None, None
|
||||
logger.info("[Auto-Import] Re-identify hint for %s → %s '%s' (%s)",
|
||||
candidate.name, hint.album_type or 'release',
|
||||
hint.album_name or '?', hint.source)
|
||||
return hint, build_identification_from_hint(hint)
|
||||
except Exception as e:
|
||||
logger.debug("[Auto-Import] rematch-hint lookup skipped: %s", e)
|
||||
return None, None
|
||||
|
||||
def _finalize_rematch_hint(self, hint, new_paths=None) -> None:
|
||||
"""Post-success: delete the replaced library row + file (if the user chose
|
||||
replace) and consume the hint so it's single-use. ``new_paths`` are where the
|
||||
re-import landed — passed through so the same-home guard never deletes a file
|
||||
the import wrote at the old location. Best-effort — a cleanup failure is
|
||||
logged, never raised, since the re-import already succeeded."""
|
||||
try:
|
||||
from core.imports.rematch_hints import consume_hint, delete_replaced_track
|
||||
|
||||
def _resolve_old(stored):
|
||||
# The old row's path is a STORED path (Docker/media-server view) — map
|
||||
# it to a file this process can actually unlink, same as everywhere else.
|
||||
try:
|
||||
from core.library.path_resolver import resolve_library_file_path
|
||||
return resolve_library_file_path(stored, config_manager=getattr(self, '_config_manager', None))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
conn = self.database._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
removed = delete_replaced_track(cursor, hint.replace_track_id,
|
||||
resolve_fn=_resolve_old, new_paths=new_paths)
|
||||
consume_hint(cursor, hint.id)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
if removed:
|
||||
logger.info("[Auto-Import] Re-identify replaced old track — removed %s", removed)
|
||||
except Exception as e:
|
||||
logger.warning("[Auto-Import] rematch-hint finalize failed (import still OK): %s", e)
|
||||
|
||||
# ── Identification ──
|
||||
|
||||
def _identify_folder(self, candidate: FolderCandidate) -> Optional[Dict]:
|
||||
|
|
@ -1515,11 +1430,8 @@ class AutoImportWorker:
|
|||
|
||||
def _match_tracks(self, candidate: FolderCandidate, identification: Dict) -> Optional[Dict]:
|
||||
"""Match staging files to the identified album's tracklist."""
|
||||
# Singles: no album tracklist to match against — the file IS the match.
|
||||
# force_album_match (set by a re-identify hint) overrides this: even a lone
|
||||
# staged file is matched INTO the chosen album, so it inherits the album's
|
||||
# year / track number / art instead of the bare singles stub (#889).
|
||||
if not identification.get('force_album_match') and (candidate.is_single or identification.get('is_single')):
|
||||
# Singles: no album tracklist to match against — the file IS the match
|
||||
if candidate.is_single or identification.get('is_single'):
|
||||
conf = identification.get('identification_confidence', 0.7)
|
||||
track_data = {
|
||||
'name': identification.get('track_name', identification.get('album_name', '')),
|
||||
|
|
@ -1664,18 +1576,31 @@ class AutoImportWorker:
|
|||
album_name = identification.get('album_name', 'Unknown')
|
||||
image_url = identification.get('image_url', '')
|
||||
|
||||
# Parent folder artist override via import.folder_artist_override.
|
||||
# Default on to preserve the legacy Artist/Album staging behavior.
|
||||
# Users who stage mixed piles under one container folder can turn it off
|
||||
# to keep the metadata-identified artist.
|
||||
# Parent folder artist override: if the staging folder structure is
|
||||
# Artist/Albums/AlbumName or Artist/AlbumName, use the parent folder
|
||||
# as the artist name when the tag-extracted artist looks wrong.
|
||||
# This handles mixtapes/compilations where embedded tags have DJ names.
|
||||
try:
|
||||
if self._config_manager.get('import.folder_artist_override', True):
|
||||
staging_root = self._resolve_staging_path() or self.staging_path
|
||||
rel_path = os.path.relpath(candidate.path, staging_root)
|
||||
folder_artist = resolve_folder_artist(rel_path, artist_name, enabled=True)
|
||||
if folder_artist:
|
||||
logger.info(f"[Auto-Import] Parent folder artist '{folder_artist}' differs from tag artist '{artist_name}' — using folder artist")
|
||||
artist_name = folder_artist
|
||||
staging_root = self._resolve_staging_path() or self.staging_path
|
||||
rel_path = os.path.relpath(candidate.path, staging_root)
|
||||
parts = [p for p in rel_path.replace('\\', '/').split('/') if p]
|
||||
|
||||
# parts[0] = artist folder, parts[1] = album or category subfolder, etc.
|
||||
# Only attempt override if there's at least 2 levels (artist/album)
|
||||
folder_artist = None
|
||||
if len(parts) >= 2:
|
||||
_category_names = {'albums', 'singles', 'eps', 'compilations', 'mixtapes',
|
||||
'discography', 'music', 'downloads'}
|
||||
if len(parts) >= 3 and parts[1].lower() in _category_names:
|
||||
# Artist/Albums/AlbumFolder → parts[0] is artist
|
||||
folder_artist = parts[0]
|
||||
elif parts[0].lower() not in _category_names:
|
||||
# Artist/AlbumFolder → parts[0] is artist
|
||||
folder_artist = parts[0]
|
||||
|
||||
if folder_artist and folder_artist.lower() != artist_name.lower():
|
||||
logger.info(f"[Auto-Import] Parent folder artist '{folder_artist}' differs from tag artist '{artist_name}' — using folder artist")
|
||||
artist_name = folder_artist
|
||||
except Exception as e:
|
||||
logger.debug("folder artist override failed: %s", e)
|
||||
release_date = identification.get('release_date', '') or album_data.get('release_date', '')
|
||||
|
|
@ -1687,7 +1612,6 @@ class AutoImportWorker:
|
|||
|
||||
processed = 0
|
||||
errors = []
|
||||
reid_final_paths = [] # #889: where the pipeline landed each file (same-home guard)
|
||||
all_matches = list(match_result.get('matches', []))
|
||||
|
||||
# Album total duration — sum of every matched track's duration.
|
||||
|
|
@ -1868,11 +1792,6 @@ class AutoImportWorker:
|
|||
|
||||
self._process_callback(context_key, context, file_path)
|
||||
processed += 1
|
||||
# Capture where the pipeline actually landed the file (#889 same-home
|
||||
# guard) — the pipeline writes it back into the mutable context.
|
||||
_landed = context.get('_final_processed_path')
|
||||
if _landed:
|
||||
reid_final_paths.append(_landed)
|
||||
logger.info(f"[Auto-Import] Processed: {track_number}. {track_name}")
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -1896,13 +1815,6 @@ class AutoImportWorker:
|
|||
except Exception as e:
|
||||
logger.debug("automation emit failed: %s", e)
|
||||
|
||||
# Stash landing paths on the candidate so _finalize_rematch_hint can avoid
|
||||
# deleting a file the re-import landed at the SAME place (#889).
|
||||
try:
|
||||
candidate._reid_final_paths = reid_final_paths
|
||||
except Exception as e:
|
||||
logger.debug("could not stash reid final paths: %s", e)
|
||||
|
||||
return processed > 0
|
||||
|
||||
# ── Database ──
|
||||
|
|
|
|||
|
|
@ -171,7 +171,12 @@ ACTIONS: list[dict] = [
|
|||
{"type": "update_discovery_pool", "label": "Update Discovery", "icon": "compass",
|
||||
"description": "Refresh discovery pool with new tracks", "available": True},
|
||||
{"type": "start_quality_scan", "label": "Run Quality Scan", "icon": "bar-chart",
|
||||
"description": "Run the Quality Upgrade Finder (scope is set in Library Maintenance)", "available": True},
|
||||
"description": "Scan for low-quality audio files", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "scope", "type": "select", "label": "Scope",
|
||||
"options": [{"value": "watchlist", "label": "Watchlist Artists"}, {"value": "library", "label": "Full Library"}],
|
||||
"default": "watchlist"}
|
||||
]},
|
||||
{"type": "backup_database", "label": "Backup Database", "icon": "save",
|
||||
"description": "Create timestamped database backup", "available": True},
|
||||
{"type": "refresh_beatport_cache", "label": "Refresh Beatport Cache", "icon": "music",
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ from __future__ import annotations
|
|||
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -105,8 +105,6 @@ class AutomationDeps:
|
|||
# --- Playlist pipeline entry points ---
|
||||
run_playlist_discovery_worker: Callable[..., Any]
|
||||
run_sync_task: Callable[..., Any]
|
||||
run_playlist_organize_download: Callable[..., Dict[str, Any]]
|
||||
missing_download_executor: Any
|
||||
load_sync_status_file: Callable[[], dict]
|
||||
get_deezer_client: Callable[[], Any]
|
||||
parse_youtube_playlist: Callable[[str], Any]
|
||||
|
|
@ -123,10 +121,10 @@ class AutomationDeps:
|
|||
duplicate_cleaner_lock: Any
|
||||
duplicate_cleaner_executor: Any
|
||||
run_duplicate_cleaner: Callable[..., Any]
|
||||
# Triggers a "Run Now" of a library-maintenance repair job by id (e.g.
|
||||
# 'quality_upgrade'). Returns truthy if the job was queued. Replaces the old
|
||||
# standalone quality-scanner executor/state (the scanner is now a repair job).
|
||||
run_repair_job_now: Callable[[str], Any]
|
||||
get_quality_scanner_state: Callable[[], dict]
|
||||
quality_scanner_lock: Any
|
||||
quality_scanner_executor: Any
|
||||
run_quality_scanner: Callable[..., Any]
|
||||
|
||||
# --- Download orchestrator + queue accessors ---
|
||||
download_orchestrator: Any
|
||||
|
|
|
|||
|
|
@ -148,45 +148,9 @@ def run_sync_and_wishlist(
|
|||
log_type='success' if sync_errors == 0 else 'warning',
|
||||
)
|
||||
|
||||
organize_playlists = [pl for pl in playlists if pl.get('organize_by_playlist')]
|
||||
organize_started = 0
|
||||
if organize_playlists and hasattr(deps, 'run_playlist_organize_download'):
|
||||
for pl in organize_playlists:
|
||||
pl_id = pl.get('id')
|
||||
if not pl_id:
|
||||
continue
|
||||
pl_name = pl.get('name', '')
|
||||
try:
|
||||
org_result = deps.run_playlist_organize_download(
|
||||
mirrored_playlist_id=int(pl_id),
|
||||
automation_id=automation_id,
|
||||
)
|
||||
if org_result.get('status') == 'started':
|
||||
organize_started += 1
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Organize download started for "{pl_name}"',
|
||||
log_type='success',
|
||||
)
|
||||
elif org_result.get('status') == 'skipped':
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Organize download skipped for "{pl_name}": {org_result.get("reason", "")}',
|
||||
log_type='skip',
|
||||
)
|
||||
except Exception as org_err: # noqa: BLE001
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Organize download error for "{pl_name}": {org_err}',
|
||||
log_type='warning',
|
||||
)
|
||||
|
||||
all_organize = bool(playlists) and len(organize_playlists) == len(playlists)
|
||||
effective_skip_wishlist = skip_wishlist or all_organize
|
||||
|
||||
wishlist_queued = run_wishlist_phase(
|
||||
deps, automation_id,
|
||||
skip=effective_skip_wishlist,
|
||||
skip=skip_wishlist,
|
||||
progress_pct=progress_end + 1,
|
||||
wishlist_phase_label=wishlist_phase_label,
|
||||
wishlist_phase_start_log=wishlist_phase_start_log,
|
||||
|
|
@ -197,7 +161,6 @@ def run_sync_and_wishlist(
|
|||
'skipped': total_skipped,
|
||||
'errors': sync_errors,
|
||||
'wishlist_queued': wishlist_queued,
|
||||
'organize_downloads_started': organize_started,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -21,49 +21,12 @@ from typing import Any, Dict
|
|||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
# Time out on STALL (no progress), not total runtime: a large library can scan
|
||||
# for many hours while progressing fine — a hard total cap would falsely mark a
|
||||
# healthy scan 'error' (the scan thread keeps running uncancelled). We only give
|
||||
# up when progress hasn't moved for a long stretch, with a generous absolute
|
||||
# backstop against a truly stuck monitor loop.
|
||||
_STALL_WARNING_SECONDS = 600 # warn after 10 min with no progress (repeats)
|
||||
_STALL_TIMEOUT_SECONDS = 1800 # 30 min with no progress at all = genuinely stalled
|
||||
_ABSOLUTE_CAP_SECONDS = 86400 # 24h hard backstop (runaway-loop guard only)
|
||||
_TIMEOUT_SECONDS = 7200 # 2 hours — covers the worst large-library case
|
||||
_STALL_WARNING_SECONDS = 600 # 10 minutes without progress = stall
|
||||
_POLL_INTERVAL_SECONDS = 3
|
||||
_INITIAL_DELAY_SECONDS = 1
|
||||
|
||||
|
||||
def scan_wait_action(
|
||||
*,
|
||||
status: str,
|
||||
idle_seconds: float,
|
||||
total_seconds: float,
|
||||
stall_timeout_s: float = _STALL_TIMEOUT_SECONDS,
|
||||
stall_warn_s: float = _STALL_WARNING_SECONDS,
|
||||
abs_cap_s: float = _ABSOLUTE_CAP_SECONDS,
|
||||
) -> str:
|
||||
"""Decide what the monitor loop should do on a poll tick (pure/testable).
|
||||
|
||||
``idle_seconds`` is time since progress last changed; ``total_seconds`` is
|
||||
time since the wait began. Returns one of:
|
||||
``'finished'`` (task no longer running), ``'stall_timeout'`` (no progress for
|
||||
too long → give up), ``'abs_timeout'`` (absolute backstop), ``'warn'``
|
||||
(stalled long enough to warn but not give up), or ``'continue'``.
|
||||
|
||||
Crucially, an actively-progressing scan keeps resetting ``idle_seconds``, so
|
||||
it never hits ``stall_timeout`` no matter how long the whole scan takes.
|
||||
"""
|
||||
if status != 'running':
|
||||
return 'finished'
|
||||
if total_seconds >= abs_cap_s:
|
||||
return 'abs_timeout'
|
||||
if idle_seconds >= stall_timeout_s:
|
||||
return 'stall_timeout'
|
||||
if idle_seconds >= stall_warn_s:
|
||||
return 'warn'
|
||||
return 'continue'
|
||||
|
||||
|
||||
def auto_start_database_update(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Run a full or incremental DB update via ``run_db_update_task``."""
|
||||
return _run_with_progress(
|
||||
|
|
@ -73,7 +36,7 @@ def auto_start_database_update(config: Dict[str, Any], deps: AutomationDeps) ->
|
|||
initial_phase='Initializing...',
|
||||
stall_label='Database update',
|
||||
finished_extras=lambda: {'full_refresh': str(config.get('full_refresh', False))},
|
||||
timeout_label='Database update timed out after 24 hours',
|
||||
timeout_label='Database update timed out after 2 hours',
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -86,7 +49,7 @@ def auto_deep_scan_library(config: Dict[str, Any], deps: AutomationDeps) -> Dict
|
|||
initial_phase='Deep scan: Initializing...',
|
||||
stall_label='Deep scan',
|
||||
finished_extras=lambda: {},
|
||||
timeout_label='Deep scan timed out after 24 hours',
|
||||
timeout_label='Deep scan timed out after 2 hours',
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -120,54 +83,30 @@ def _run_with_progress(
|
|||
deps.db_update_executor.submit(task, *task_args)
|
||||
|
||||
# Monitor progress (callbacks handle card updates, we just block until done).
|
||||
# We time out on STALL, not total runtime: ``processed`` advances on every
|
||||
# artist, so an actively-progressing scan keeps resetting the idle clock and
|
||||
# is never falsely failed no matter how long the whole library takes.
|
||||
time.sleep(_INITIAL_DELAY_SECONDS)
|
||||
poll_start = time.time()
|
||||
last_progress_time = time.time()
|
||||
# Any of these advancing means the scan is alive. current_item (the artist
|
||||
# being processed) changes every artist even when the rounded progress %
|
||||
# holds steady, so it guards against a false stall during slow stretches.
|
||||
last_progress_val = (0, 0, '')
|
||||
last_warn_time = 0.0
|
||||
outcome = 'finished'
|
||||
while True:
|
||||
last_progress_val = 0
|
||||
while time.time() - poll_start < _TIMEOUT_SECONDS:
|
||||
time.sleep(_POLL_INTERVAL_SECONDS)
|
||||
now = time.time()
|
||||
with deps.db_update_lock:
|
||||
current_status = state.get('status', 'idle')
|
||||
current_val = (state.get('processed', 0), state.get('progress', 0),
|
||||
state.get('current_item', ''))
|
||||
if current_val != last_progress_val:
|
||||
last_progress_val = current_val
|
||||
last_progress_time = now
|
||||
|
||||
action = scan_wait_action(
|
||||
status=current_status,
|
||||
idle_seconds=now - last_progress_time,
|
||||
total_seconds=now - poll_start,
|
||||
)
|
||||
if action in ('finished', 'stall_timeout', 'abs_timeout'):
|
||||
outcome = action
|
||||
current_progress = state.get('progress', 0)
|
||||
if current_status != 'running':
|
||||
break
|
||||
if action == 'warn' and (now - last_warn_time) > _STALL_WARNING_SECONDS:
|
||||
idle_min = int((now - last_progress_time) / 60)
|
||||
# Stall detection — if no progress change in 10 minutes, warn.
|
||||
if current_progress != last_progress_val:
|
||||
last_progress_val = current_progress
|
||||
last_progress_time = time.time()
|
||||
elif time.time() - last_progress_time > _STALL_WARNING_SECONDS:
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'{stall_label} — no progress for {idle_min} min, still waiting...',
|
||||
log_line=f'{stall_label} appears stalled — waiting...',
|
||||
log_type='warning',
|
||||
)
|
||||
last_warn_time = now
|
||||
|
||||
if outcome == 'stall_timeout':
|
||||
deps.update_progress(
|
||||
automation_id, status='error', phase='Stalled',
|
||||
log_line=f'{stall_label} made no progress for {_STALL_TIMEOUT_SECONDS // 60} minutes — giving up',
|
||||
log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': 'Stalled (no progress)', '_manages_own_progress': True}
|
||||
if outcome == 'abs_timeout':
|
||||
last_progress_time = time.time() # Reset so warning repeats every 10 min.
|
||||
else:
|
||||
# 2-hour timeout reached.
|
||||
deps.update_progress(
|
||||
automation_id, status='error',
|
||||
phase='Timed out', log_line=timeout_label, log_type='error',
|
||||
|
|
|
|||
|
|
@ -115,27 +115,19 @@ def auto_backup_database(config: Dict[str, Any], deps: AutomationDeps) -> Dict[s
|
|||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_path = f"{db_path}.backup_{timestamp}"
|
||||
# safe_backup verifies source + result integrity, so an automated backup
|
||||
# can never silently snapshot a corrupt DB (the incident where every
|
||||
# rolling backup faithfully copied the corruption).
|
||||
from core.db_integrity import DBIntegrityError, safe_backup, prune_backups
|
||||
try:
|
||||
safe_backup(db_path, backup_path)
|
||||
except DBIntegrityError as integ:
|
||||
deps.logger.error("Auto-backup refused — DB integrity check failed: %s", integ)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Backup SKIPPED — database failed integrity check: {integ}',
|
||||
log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': f'Database integrity check failed: {integ}'}
|
||||
# Use SQLite backup API for a safe hot-copy of an active database.
|
||||
src = sqlite3.connect(db_path)
|
||||
dst = sqlite3.connect(backup_path)
|
||||
src.backup(dst)
|
||||
dst.close()
|
||||
src.close()
|
||||
size_mb = round(os.path.getsize(backup_path) / (1024 * 1024), 1)
|
||||
|
||||
# Rolling cleanup — never evict the most-recent verified-healthy backup.
|
||||
existing = list(_glob.glob(f"{db_path}.backup_*"))
|
||||
for removed in prune_backups(existing, _MAX_BACKUPS):
|
||||
# Rolling cleanup — keep only the newest N backups.
|
||||
existing = sorted(_glob.glob(f"{db_path}.backup_*"), key=os.path.getmtime)
|
||||
while len(existing) > _MAX_BACKUPS:
|
||||
try:
|
||||
os.remove(removed)
|
||||
os.remove(existing.pop(0))
|
||||
except Exception as e: # noqa: BLE001 — best-effort cleanup
|
||||
deps.logger.debug("rolling backup cleanup failed: %s", e)
|
||||
deps.update_progress(
|
||||
|
|
|
|||
|
|
@ -1,35 +1,83 @@
|
|||
"""Automation handler: ``start_quality_scan`` action.
|
||||
|
||||
The quality scanner was redesigned from an auto-acting tool into the
|
||||
``quality_upgrade`` library-maintenance repair job (findings-based, reviewed
|
||||
before anything is wishlisted). This action now simply triggers a "Run Now" of
|
||||
that job; its progress and findings surface in Library Maintenance. The action
|
||||
name is kept so existing automation rules keep working.
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_start_quality_scan`` closure). Submits the quality scanner
|
||||
to its executor with the configured scope (default: ``watchlist``)
|
||||
then polls the shared state dict.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
_TIMEOUT_SECONDS = 7200 # 2 hours
|
||||
_POLL_INTERVAL_SECONDS = 3
|
||||
_INITIAL_DELAY_SECONDS = 1
|
||||
|
||||
|
||||
def auto_start_quality_scan(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
automation_id = config.get('_automation_id')
|
||||
state = deps.get_quality_scanner_state()
|
||||
if state.get('status') == 'running':
|
||||
return {'status': 'skipped', 'reason': 'Quality scan already running'}
|
||||
|
||||
triggered = deps.run_repair_job_now('quality_upgrade')
|
||||
if not triggered:
|
||||
scope = config.get('scope', 'watchlist')
|
||||
# Pre-set status before submit so the polling loop doesn't see a
|
||||
# stale 'finished' from a previous run.
|
||||
with deps.quality_scanner_lock:
|
||||
state['status'] = 'running'
|
||||
deps.quality_scanner_executor.submit(deps.run_quality_scanner, scope, deps.get_current_profile_id())
|
||||
deps.update_progress(
|
||||
automation_id, log_line=f'Quality scan started (scope: {scope})', log_type='info',
|
||||
)
|
||||
|
||||
# Monitor progress (max 2 hours).
|
||||
time.sleep(_INITIAL_DELAY_SECONDS)
|
||||
poll_start = time.time()
|
||||
while time.time() - poll_start < _TIMEOUT_SECONDS:
|
||||
time.sleep(_POLL_INTERVAL_SECONDS)
|
||||
current_status = state.get('status', 'idle')
|
||||
if current_status not in ('running',):
|
||||
break
|
||||
deps.update_progress(
|
||||
automation_id, status='error', phase='Unavailable',
|
||||
log_line='Quality Upgrade job could not be triggered (library worker unavailable)',
|
||||
automation_id,
|
||||
phase=state.get('phase', 'Scanning...'),
|
||||
progress=state.get('progress', 0),
|
||||
processed=state.get('processed', 0),
|
||||
total=state.get('total', 0),
|
||||
)
|
||||
else:
|
||||
deps.update_progress(
|
||||
automation_id, status='error',
|
||||
phase='Timed out', log_line='Quality scan timed out after 2 hours',
|
||||
log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': 'library worker unavailable',
|
||||
'_manages_own_progress': True}
|
||||
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True}
|
||||
|
||||
final_status = state.get('status', 'idle')
|
||||
if final_status == 'error':
|
||||
err = state.get('error_message', 'Unknown error')
|
||||
deps.update_progress(
|
||||
automation_id, status='error', progress=100,
|
||||
phase='Error', log_line=err, log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': err, '_manages_own_progress': True}
|
||||
|
||||
issues = state.get('low_quality', 0)
|
||||
deps.update_progress(
|
||||
automation_id, status='finished', progress=100, phase='Triggered',
|
||||
log_line='Quality Upgrade scan queued — findings appear in Library Maintenance',
|
||||
automation_id, status='finished', progress=100,
|
||||
phase='Complete',
|
||||
log_line=f'Quality scan complete — {issues} issues found',
|
||||
log_type='success',
|
||||
)
|
||||
return {'status': 'completed', 'triggered': True, '_manages_own_progress': True}
|
||||
return {
|
||||
'status': 'completed', 'scope': scope, '_manages_own_progress': True,
|
||||
'tracks_scanned': state.get('processed', 0),
|
||||
'quality_met': state.get('quality_met', 0),
|
||||
'low_quality': issues,
|
||||
'matched': state.get('matched', 0),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -292,18 +292,6 @@ def _commit_refresh(
|
|||
image_url=pl.get('image_url'),
|
||||
)
|
||||
|
||||
# Membership just changed — if this playlist is organize-by-playlist, rebuild
|
||||
# its folder (with prune) so a track that LEFT the playlist has its symlink
|
||||
# cleaned up now. Gated to organized playlists, non-fatal — never disturbs
|
||||
# the refresh. (Additions are handled by the post-download reconcile.)
|
||||
try:
|
||||
from core.playlists.materialize_service import rebuild_mirrored_playlist_if_organized
|
||||
rebuild_mirrored_playlist_if_organized(
|
||||
db, deps.config_manager, pl.get('id'), profile_id=pl.get('profile_id', 1)
|
||||
)
|
||||
except Exception as _mat_err:
|
||||
deps.logger.debug(f"[Playlist Folder] mirror-refresh cleanup skipped: {_mat_err}")
|
||||
|
||||
if old_ids != new_ids:
|
||||
added = len(new_ids - old_ids)
|
||||
removed = len(old_ids - new_ids)
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ def register_all(deps: AutomationDeps) -> None:
|
|||
engine.register_action_handler(
|
||||
'start_quality_scan',
|
||||
lambda config: auto_start_quality_scan(config, deps),
|
||||
lambda: False, # repair worker dedupes Run-Now requests itself
|
||||
lambda: deps.get_quality_scanner_state().get('status') == 'running',
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'backup_database',
|
||||
|
|
|
|||
|
|
@ -145,36 +145,13 @@ def auto_sync_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str
|
|||
tracks_hash = hashlib.md5(track_ids_str.encode()).hexdigest()
|
||||
|
||||
sync_id_key = f"auto_mirror_{playlist_id}"
|
||||
# Full mirror identity (every source_track_id on the playlist). tracks_hash
|
||||
# only covers tracks_json — if a new mirror row is skipped (no discovery /
|
||||
# no source id), tracks_hash stays identical to the pre-add sync and we
|
||||
# used to no-op with "unchanged" while the new song never hit wishlist.
|
||||
mirror_ids_str = ','.join(
|
||||
sorted(t.get('source_track_id', '') or '' for t in tracks if t.get('source_track_id'))
|
||||
)
|
||||
mirror_tracks_hash = hashlib.md5(mirror_ids_str.encode()).hexdigest() if mirror_ids_str else ''
|
||||
|
||||
event_data = config.get('_event_data') or {}
|
||||
try:
|
||||
tracks_added = int(event_data.get('added') or 0)
|
||||
except (TypeError, ValueError):
|
||||
tracks_added = 0
|
||||
force_sync = tracks_added > 0 or skipped_count > 0
|
||||
|
||||
try:
|
||||
sync_statuses = deps.load_sync_status_file()
|
||||
last_status = sync_statuses.get(sync_id_key, {})
|
||||
last_hash = last_status.get('tracks_hash', '')
|
||||
last_mirror_hash = last_status.get('mirror_tracks_hash', '')
|
||||
last_matched = last_status.get('matched_tracks', -1)
|
||||
|
||||
mirror_changed = bool(mirror_tracks_hash) and mirror_tracks_hash != last_mirror_hash
|
||||
if (
|
||||
not force_sync
|
||||
and not mirror_changed
|
||||
and last_hash == tracks_hash
|
||||
and last_matched >= len(tracks_json)
|
||||
):
|
||||
if last_hash == tracks_hash and last_matched >= len(tracks_json):
|
||||
# Exact same tracks, all matched last time — nothing to do.
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
|
|
@ -185,33 +162,13 @@ def auto_sync_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str
|
|||
'status': 'skipped',
|
||||
'reason': f'All {len(tracks_json)} tracks unchanged since last sync',
|
||||
}
|
||||
if force_sync and last_hash == tracks_hash and last_matched >= len(tracks_json):
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=(
|
||||
f'Forcing sync: playlist changed ({tracks_added} added) or '
|
||||
f'{skipped_count} track(s) need discovery'
|
||||
),
|
||||
log_type='info',
|
||||
)
|
||||
elif mirror_changed:
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line='Mirror track list changed — running sync',
|
||||
log_type='info',
|
||||
)
|
||||
except Exception as e:
|
||||
deps.logger.debug("mirror sync last-status read: %s", e)
|
||||
|
||||
# Sync under the user's custom alias when set, else the upstream name (#865
|
||||
# follow-up). The server-side playlist is named with this.
|
||||
from core.playlists.naming import effective_mirrored_name
|
||||
sync_name = effective_mirrored_name(pl) or pl.get('name') or 'Playlist'
|
||||
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
progress=50,
|
||||
phase=f'Syncing "{sync_name}"',
|
||||
phase=f'Syncing "{pl["name"]}"',
|
||||
log_line=f'{len(tracks_json)} discovered, {skipped_count} skipped',
|
||||
log_type='info',
|
||||
)
|
||||
|
|
@ -223,17 +180,15 @@ def auto_sync_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str
|
|||
log_line=f'Starting sync: {len(tracks_json)} tracks',
|
||||
log_type='success',
|
||||
)
|
||||
skip_wishlist_add = bool(pl.get('organize_by_playlist'))
|
||||
threading.Thread(
|
||||
target=deps.run_sync_task,
|
||||
args=(sync_id, sync_name, tracks_json, auto_id, 1, pl.get('image_url', '')),
|
||||
kwargs={'skip_wishlist_add': skip_wishlist_add},
|
||||
args=(sync_id, pl['name'], tracks_json, auto_id, 1, pl.get('image_url', '')),
|
||||
daemon=True,
|
||||
name=f'auto-sync-{playlist_id}',
|
||||
).start()
|
||||
return {
|
||||
'status': 'started',
|
||||
'playlist_name': sync_name,
|
||||
'playlist_name': pl['name'],
|
||||
'discovered_tracks': str(len(tracks_json)),
|
||||
'skipped_tracks': str(skipped_count),
|
||||
'_manages_own_progress': True,
|
||||
|
|
|
|||
|
|
@ -635,11 +635,6 @@ class AutomationEngine:
|
|||
action_config['_automation_name'] = auto.get('name', '')
|
||||
if profile_id is not None:
|
||||
action_config['_profile_id'] = profile_id
|
||||
# The profile this run acts AS: an explicit trigger profile, else the
|
||||
# automation's owner, else admin. System + admin automations are
|
||||
# profile 1, so this is a no-op for them — only non-admin-owned
|
||||
# automations gain their correct identity in the background.
|
||||
_effective_profile_id = profile_id if profile_id is not None else (auto.get('profile_id') or 1)
|
||||
|
||||
# Action delay (skipped for manual run_now)
|
||||
delay_minutes = action_config.get('delay', 0)
|
||||
|
|
@ -686,14 +681,9 @@ class AutomationEngine:
|
|||
except Exception as e:
|
||||
logger.debug("scheduled progress init: %s", e)
|
||||
|
||||
# Execute the action under the owner's profile so get_current_profile_id()
|
||||
# (and the per-profile clients it resolves) act as the automation's owner
|
||||
# in the background, not admin. Reset in finally so a pooled thread can't
|
||||
# leak the override to the next job.
|
||||
# Execute the action
|
||||
error = None
|
||||
result = {}
|
||||
from core.profile_context import set_background_profile, reset_background_profile
|
||||
_bg_token = set_background_profile(_effective_profile_id)
|
||||
try:
|
||||
result = handler_info['handler'](action_config) or {}
|
||||
logger.info(f"Automation '{auto['name']}' (id={automation_id}) executed: {result.get('status', 'ok')}")
|
||||
|
|
@ -712,8 +702,6 @@ class AutomationEngine:
|
|||
error = str(e)
|
||||
result = {'status': 'error', 'error': error}
|
||||
logger.error(f"Automation '{auto['name']}' (id={automation_id}) failed: {e}")
|
||||
finally:
|
||||
reset_background_profile(_bg_token)
|
||||
|
||||
# Finalize progress tracking
|
||||
if self._progress_finish_fn:
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
"""Artist / album / track blocklist (the "proper" blacklist).
|
||||
|
||||
Distinct from ``download_blacklist`` (which skips one bad source file from one
|
||||
Soulseek peer — untouched here). This blocklist bans an ARTIST, ALBUM, or
|
||||
TRACK from being acquired, keyed by metadata-source IDs (Spotify / iTunes /
|
||||
Deezer / MusicBrainz) so a ban survives a source switch.
|
||||
|
||||
Phase 1 enforces at the single ``add_to_wishlist`` chokepoint: every
|
||||
auto-acquisition path (watchlist, discography backfill, repair, manual
|
||||
wishlist add) funnels through it, so one guard covers them all.
|
||||
|
||||
- ``matching`` — the pure decision core (no DB, no I/O): build an index from
|
||||
blocklist rows, ask whether a candidate is blocked, with artist→album→track
|
||||
cascade.
|
||||
"""
|
||||
|
||||
from core.blocklist.matching import (
|
||||
ENTITY_ALBUM,
|
||||
ENTITY_ARTIST,
|
||||
ENTITY_TRACK,
|
||||
ENTITY_TYPES,
|
||||
SOURCE_ID_FIELDS,
|
||||
BlocklistIndex,
|
||||
build_index,
|
||||
candidate_block_reason,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ENTITY_ARTIST",
|
||||
"ENTITY_ALBUM",
|
||||
"ENTITY_TRACK",
|
||||
"ENTITY_TYPES",
|
||||
"SOURCE_ID_FIELDS",
|
||||
"BlocklistIndex",
|
||||
"build_index",
|
||||
"candidate_block_reason",
|
||||
]
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
"""Cross-source ID backfill for blocklist entries.
|
||||
|
||||
When a user blocks an item, the modal gives us the ID for ONE source (the one
|
||||
they searched). For the ban to survive a source switch, we resolve the OTHER
|
||||
sources' IDs too — matching the blocked artist/album/track by name on each
|
||||
source and taking a confident hit.
|
||||
|
||||
The resolution is kept pure + injected so it tests without a network: callers
|
||||
pass a ``resolvers`` map ``{source: fn(entity_type, name, parent_name) -> id |
|
||||
None}``. ``core/blocklist/runtime.py`` wires the real metadata clients.
|
||||
|
||||
Honest about fragility (acknowledged in design): artist matching is reliable,
|
||||
album/track cross-source matching is best-effort (editions, common titles), so
|
||||
a resolver returning None just leaves that source unmatched — the artist
|
||||
name-fallback in matching.py covers artist gaps; album/track gaps mean that
|
||||
ban only applies on sources where an ID resolved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from core.blocklist.matching import SOURCE_ID_FIELDS
|
||||
|
||||
|
||||
def resolve_missing_ids(
|
||||
entry: Dict[str, Any],
|
||||
resolvers: Dict[str, Callable[..., Optional[str]]],
|
||||
) -> Dict[str, str]:
|
||||
"""Return ``{id_column: resolved_id}`` for the sources currently missing an
|
||||
ID on ``entry``. Never raises — a resolver that errors is skipped."""
|
||||
out: Dict[str, str] = {}
|
||||
entity_type = entry.get("entity_type")
|
||||
name = entry.get("name")
|
||||
parent = entry.get("parent_name")
|
||||
if not entity_type or not name:
|
||||
return out
|
||||
for source, col in SOURCE_ID_FIELDS.items():
|
||||
if entry.get(col):
|
||||
continue # already known
|
||||
fn = resolvers.get(source)
|
||||
if not fn:
|
||||
continue
|
||||
try:
|
||||
rid = fn(entity_type, name, parent)
|
||||
except Exception:
|
||||
rid = None
|
||||
if rid:
|
||||
out[col] = str(rid)
|
||||
return out
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
"""Pure blocklist matching — no DB, no I/O, fully unit-testable.
|
||||
|
||||
The brain of the blocklist: given the stored blocklist rows and a candidate
|
||||
track being considered for the wishlist, decide whether it's blocked.
|
||||
|
||||
Design decisions (per Boulder):
|
||||
- **ID-keyed.** Each row carries the candidate's IDs in up to four metadata
|
||||
sources. A candidate is matched against the SAME source it came in on
|
||||
(the wishlist payload carries active-source IDs), so a Deezer-numeric id
|
||||
can't collide with an iTunes-numeric id of a different entity.
|
||||
- **Cascade.** Blocking an artist blocks their albums + tracks; blocking an
|
||||
album blocks its tracks. The candidate carries its own artist/album/track
|
||||
IDs, so the check walks track → album → artist and blocks on the first hit.
|
||||
- **Name fallback for ARTISTS only.** A blocked artist also matches by
|
||||
case-folded name — this covers the window before the background ID-backfill
|
||||
has resolved the active source's id. Albums/tracks do NOT fall back to name
|
||||
(common titles like "Greatest Hits" would false-positive across artists);
|
||||
they rely on IDs, which backfill fills in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
||||
|
||||
ENTITY_ARTIST = "artist"
|
||||
ENTITY_ALBUM = "album"
|
||||
ENTITY_TRACK = "track"
|
||||
ENTITY_TYPES = (ENTITY_ARTIST, ENTITY_ALBUM, ENTITY_TRACK)
|
||||
|
||||
# Blocklist-row column → the metadata source it belongs to.
|
||||
SOURCE_ID_FIELDS = {
|
||||
"spotify": "spotify_id",
|
||||
"itunes": "itunes_id",
|
||||
"deezer": "deezer_id",
|
||||
"musicbrainz": "musicbrainz_id",
|
||||
}
|
||||
|
||||
|
||||
def _norm(text: Any) -> str:
|
||||
return str(text or "").strip().casefold()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TypeIndex:
|
||||
# per-source set of blocked ids, plus a case-folded name set (artists only)
|
||||
ids: Dict[str, Set[str]] = field(default_factory=lambda: {s: set() for s in SOURCE_ID_FIELDS})
|
||||
names: Set[str] = field(default_factory=set)
|
||||
|
||||
def hit(self, source: Optional[str], entity_id: Any, name: Any, use_name: bool) -> bool:
|
||||
if entity_id and source in self.ids and str(entity_id) in self.ids[source]:
|
||||
return True
|
||||
if use_name and name and _norm(name) in self.names:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlocklistIndex:
|
||||
"""Membership index built once per scan from the blocklist rows."""
|
||||
artists: _TypeIndex = field(default_factory=_TypeIndex)
|
||||
albums: _TypeIndex = field(default_factory=_TypeIndex)
|
||||
tracks: _TypeIndex = field(default_factory=_TypeIndex)
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
for ti in (self.artists, self.albums, self.tracks):
|
||||
if ti.names or any(ti.ids.values()):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def build_index(rows: Iterable[Dict[str, Any]]) -> BlocklistIndex:
|
||||
"""Build a BlocklistIndex from blocklist DB rows.
|
||||
|
||||
Each row needs ``entity_type``, ``name``, and the source id columns
|
||||
(``spotify_id`` / ``itunes_id`` / ``deezer_id`` / ``musicbrainz_id``).
|
||||
Unknown entity types are ignored."""
|
||||
idx = BlocklistIndex()
|
||||
by_type = {ENTITY_ARTIST: idx.artists, ENTITY_ALBUM: idx.albums, ENTITY_TRACK: idx.tracks}
|
||||
for row in rows or []:
|
||||
ti = by_type.get((row.get("entity_type") or "").strip().lower())
|
||||
if ti is None:
|
||||
continue
|
||||
for source, col in SOURCE_ID_FIELDS.items():
|
||||
val = row.get(col)
|
||||
if val:
|
||||
ti.ids[source].add(str(val))
|
||||
name = row.get("name")
|
||||
if name:
|
||||
ti.names.add(_norm(name))
|
||||
return idx
|
||||
|
||||
|
||||
def candidate_block_reason(
|
||||
index: BlocklistIndex,
|
||||
*,
|
||||
source: Optional[str],
|
||||
track_id: Any = None,
|
||||
track_name: Any = None,
|
||||
album_id: Any = None,
|
||||
album_name: Any = None,
|
||||
artists: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
"""Return ``(entity_type, label)`` for the first cascade hit, else None.
|
||||
|
||||
``source`` is the metadata source the candidate IDs came from (the wishlist
|
||||
payload's provider). ``artists`` is a list of ``{'id', 'name'}`` dicts.
|
||||
Order matters only for the returned reason — any hit blocks."""
|
||||
if index.is_empty:
|
||||
return None
|
||||
|
||||
# Track level — id only (names too ambiguous to ban across artists).
|
||||
if index.tracks.hit(source, track_id, track_name, use_name=False):
|
||||
return (ENTITY_TRACK, str(track_name or track_id or "track"))
|
||||
|
||||
# Album level — id only.
|
||||
if index.albums.hit(source, album_id, album_name, use_name=False):
|
||||
return (ENTITY_ALBUM, str(album_name or album_id or "album"))
|
||||
|
||||
# Artist level — id OR case-folded name (safe + covers the backfill window).
|
||||
for artist in artists or []:
|
||||
a_id = artist.get("id") if isinstance(artist, dict) else None
|
||||
a_name = artist.get("name") if isinstance(artist, dict) else artist
|
||||
if index.artists.hit(source, a_id, a_name, use_name=True):
|
||||
return (ENTITY_ARTIST, str(a_name or a_id or "artist"))
|
||||
|
||||
return None
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
"""Wire real metadata clients to the blocklist backfill resolvers.
|
||||
|
||||
Resolves a blocked item's ID on each metadata source by searching that source
|
||||
for the name and taking a confidently name-matched hit. Confidence = exact
|
||||
significant-token match (drops articles/punctuation) so we never hang a wrong
|
||||
ID on an entry. Albums/tracks additionally require the parent artist to match
|
||||
when both sides expose one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("blocklist.runtime")
|
||||
|
||||
_STOP = {"the", "a", "an", "feat", "ft", "featuring", "with"}
|
||||
|
||||
|
||||
def _tokens(text: Any) -> frozenset:
|
||||
words = re.sub(r"[^a-z0-9]+", " ", str(text or "").lower()).split()
|
||||
return frozenset(w for w in words if w not in _STOP)
|
||||
|
||||
|
||||
def _name_of(obj: Any) -> str:
|
||||
if isinstance(obj, dict):
|
||||
return str(obj.get("name") or obj.get("title") or "")
|
||||
return str(getattr(obj, "name", None) or getattr(obj, "title", None) or "")
|
||||
|
||||
|
||||
def _id_of(obj: Any) -> Optional[str]:
|
||||
val = obj.get("id") if isinstance(obj, dict) else getattr(obj, "id", None)
|
||||
return str(val) if val else None
|
||||
|
||||
|
||||
def _confident(result_name: str, want_name: str) -> bool:
|
||||
rt, wt = _tokens(result_name), _tokens(want_name)
|
||||
return bool(rt) and rt == wt
|
||||
|
||||
|
||||
def _make_resolver(source: str) -> Callable[..., Optional[str]]:
|
||||
def resolve(entity_type: str, name: str, parent_name: Optional[str] = None) -> Optional[str]:
|
||||
from core.metadata.registry import get_client_for_source
|
||||
client = get_client_for_source(source)
|
||||
if not client:
|
||||
return None
|
||||
method = {
|
||||
"artist": "search_artists",
|
||||
"album": "search_albums",
|
||||
"track": "search_tracks",
|
||||
}.get(entity_type)
|
||||
fn = getattr(client, method, None) if method else None
|
||||
if not fn:
|
||||
return None
|
||||
try:
|
||||
results = fn(name, limit=5) or []
|
||||
except Exception as e:
|
||||
logger.debug("%s %s search failed for %r: %s", source, entity_type, name, e)
|
||||
return None
|
||||
for r in results:
|
||||
if not _confident(_name_of(r), name):
|
||||
continue
|
||||
# For album/track, also require the artist to line up when known.
|
||||
if entity_type in ("album", "track") and parent_name:
|
||||
artists = (r.get("artists") if isinstance(r, dict) else getattr(r, "artists", None)) or []
|
||||
cand_artists = " ".join(
|
||||
a.get("name", "") if isinstance(a, dict) else str(a) for a in artists)
|
||||
if _tokens(parent_name) and not (_tokens(parent_name) & _tokens(cand_artists)):
|
||||
continue
|
||||
rid = _id_of(r)
|
||||
if rid:
|
||||
return rid
|
||||
return None
|
||||
|
||||
return resolve
|
||||
|
||||
|
||||
def build_resolvers() -> Dict[str, Callable[..., Optional[str]]]:
|
||||
"""Source→resolver map for core.blocklist.backfill.resolve_missing_ids."""
|
||||
return {s: _make_resolver(s) for s in ("spotify", "itunes", "deezer", "musicbrainz")}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
"""Boot-phase guard for non-blocking container startup.
|
||||
|
||||
While the gunicorn worker is importing ``web_server`` (module-level client and
|
||||
worker initialization), external provider API probes must not block startup.
|
||||
Network validation is deferred until ``mark_boot_complete()`` runs at the end
|
||||
of that import pass.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
_boot_lock = threading.Lock()
|
||||
_boot_active = True
|
||||
|
||||
|
||||
def is_boot_phase() -> bool:
|
||||
"""Return True while module import must avoid blocking provider API calls."""
|
||||
with _boot_lock:
|
||||
return _boot_active
|
||||
|
||||
|
||||
def mark_boot_complete() -> None:
|
||||
"""End the boot phase — provider clients may perform network probes again."""
|
||||
global _boot_active
|
||||
with _boot_lock:
|
||||
_boot_active = False
|
||||
|
|
@ -82,16 +82,7 @@ def run_service_test(service, test_config):
|
|||
if temp_client.is_spotify_authenticated():
|
||||
return True, "Spotify connection successful!"
|
||||
else:
|
||||
# Spotify-Free (no-auth) metadata path: officially unauthenticated,
|
||||
# but the no-creds source is selected and available. Report it as the
|
||||
# working source rather than the generic Deezer/Discogs/iTunes fallback.
|
||||
try:
|
||||
spotify_free_available = temp_client.is_spotify_metadata_available()
|
||||
except Exception:
|
||||
spotify_free_available = False
|
||||
if spotify_free_available:
|
||||
return True, "Spotify (no-auth) connection successful!"
|
||||
# Using a different fallback metadata source
|
||||
# Using fallback metadata source
|
||||
fb_src = _get_metadata_fallback_source()
|
||||
fallback_name = 'Deezer' if fb_src == 'deezer' else 'Discogs' if fb_src == 'discogs' else 'iTunes'
|
||||
if spotify_configured:
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
"""Named, switchable service-credential sets — pure logic (Phase 0 foundation).
|
||||
|
||||
Today every auth service (Spotify, Tidal, Deezer, Qobuz, Plex, Jellyfin,
|
||||
Navidrome) holds ONE credential set in config, and clients are global singletons
|
||||
built from that single slot. This module is the groundwork for letting an admin
|
||||
save MULTIPLE named credential sets per service ("pills") that each profile can
|
||||
switch between, without anyone but the admin creating them.
|
||||
|
||||
Kept PURE — service registry, payload validation, and active-set selection,
|
||||
free of DB/Flask so it's unit-testable. Encrypted storage lives in MusicDatabase
|
||||
(service_credentials / profile_service_credentials tables); runtime client
|
||||
resolution + UI come in later phases. Nothing here changes existing behaviour;
|
||||
it's dormant capability until wired.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Services that support multiple named credential sets, mapped to the payload
|
||||
# keys that MUST be present for a set to be usable. Extra keys (OAuth tokens,
|
||||
# redirect URIs, quality prefs) are allowed and preserved — these are only the
|
||||
# minimum required to validate a set the admin is saving.
|
||||
SERVICE_CREDENTIAL_SCHEMA = {
|
||||
'spotify': ('client_id', 'client_secret'),
|
||||
'tidal': ('access_token', 'refresh_token'),
|
||||
'deezer': ('arl',),
|
||||
'qobuz': ('user_auth_token',),
|
||||
'plex': ('base_url', 'token'),
|
||||
'jellyfin': ('base_url', 'api_key'),
|
||||
'navidrome': ('base_url', 'username', 'password'),
|
||||
}
|
||||
|
||||
SUPPORTED_SERVICES = frozenset(SERVICE_CREDENTIAL_SCHEMA)
|
||||
|
||||
|
||||
def is_supported_service(service: str) -> bool:
|
||||
"""True when the service supports named credential sets."""
|
||||
return service in SERVICE_CREDENTIAL_SCHEMA
|
||||
|
||||
|
||||
def validate_credential_payload(service: str, payload):
|
||||
"""Return ``(ok, missing_keys)`` for a credential set.
|
||||
|
||||
Valid when every required key for the service is present and truthy. An
|
||||
unknown service is invalid with no missing list (caller should reject it
|
||||
as unsupported, not as "incomplete").
|
||||
"""
|
||||
required = SERVICE_CREDENTIAL_SCHEMA.get(service)
|
||||
if required is None:
|
||||
return False, []
|
||||
if not isinstance(payload, dict):
|
||||
return False, list(required)
|
||||
|
||||
def _present(v):
|
||||
# Whitespace-only strings count as missing — they'd otherwise save a
|
||||
# blank secret that fails confusingly at the real service later.
|
||||
return bool(v.strip()) if isinstance(v, str) else bool(v)
|
||||
|
||||
missing = [k for k in required if not _present(payload.get(k))]
|
||||
return (not missing), missing
|
||||
|
||||
|
||||
def pick_active_credential(credentials, selected_id):
|
||||
"""From ``credentials`` (a list of dicts each carrying ``id``), return the
|
||||
one whose id == ``selected_id``.
|
||||
|
||||
Returns None when there's no selection OR the selected id isn't present —
|
||||
i.e. a stale pointer whose credential set was deleted. The caller then
|
||||
falls back to the global/admin default, so a deleted set never breaks a
|
||||
profile. Pure + stale-safe.
|
||||
"""
|
||||
if not selected_id:
|
||||
return None
|
||||
for cred in credentials or []:
|
||||
if cred.get('id') == selected_id:
|
||||
return cred
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
'SERVICE_CREDENTIAL_SCHEMA',
|
||||
'SUPPORTED_SERVICES',
|
||||
'is_supported_service',
|
||||
'validate_credential_payload',
|
||||
'pick_active_credential',
|
||||
]
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
"""Stall detection for the database-update job.
|
||||
|
||||
The DB updater keeps a single in-memory state dict whose ``status`` is set to
|
||||
``running`` at start and only flipped to ``finished``/``error`` by the worker's
|
||||
completion/error callbacks. If the worker thread hangs — e.g. a media-server API
|
||||
call with no timeout, a DB lock — those callbacks never fire, so ``status`` stays
|
||||
``running`` forever and the UI shows a frozen progress bar with no way to recover
|
||||
(GitHub #859).
|
||||
|
||||
This module is the single, *pure* decision for "is a running job stalled?". It
|
||||
takes the state dict plus the current wall-clock time and a timeout, and answers
|
||||
yes/no — no DB, no globals, no clock of its own. That keeps it unit-testable and
|
||||
lets the watchdog wiring in web_server.py stay a thin call. The job carries a
|
||||
``last_progress_at`` epoch timestamp that the start path and every progress/phase
|
||||
callback bump; staleness is simply "running, and that timestamp is older than the
|
||||
timeout".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
# 5 minutes with zero forward progress = presumed hung. A healthy scan ticks
|
||||
# progress (per-artist) far more often than this even for large libraries, so
|
||||
# the timeout won't false-positive a slow-but-working run.
|
||||
DEFAULT_STALL_TIMEOUT_SECONDS = 300
|
||||
|
||||
|
||||
def is_db_update_stalled(
|
||||
state: Mapping[str, Any],
|
||||
now: float,
|
||||
timeout_seconds: float = DEFAULT_STALL_TIMEOUT_SECONDS,
|
||||
) -> bool:
|
||||
"""Return True when the job is ``running`` but has made no progress within
|
||||
``timeout_seconds``.
|
||||
|
||||
Conservative by design — it only ever reports a stall it can prove:
|
||||
- Only a ``running`` job can stall (idle/finished/error never do).
|
||||
- With no usable ``last_progress_at`` timestamp we cannot judge, so we return
|
||||
False rather than risk killing a job we have no clock for.
|
||||
- A non-positive timeout is treated as "disabled" (never stalls).
|
||||
"""
|
||||
if not isinstance(state, Mapping):
|
||||
return False
|
||||
if state.get("status") != "running":
|
||||
return False
|
||||
if timeout_seconds is None or timeout_seconds <= 0:
|
||||
return False
|
||||
last = state.get("last_progress_at")
|
||||
if not last:
|
||||
return False
|
||||
try:
|
||||
elapsed = float(now) - float(last)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return elapsed >= float(timeout_seconds)
|
||||
|
||||
|
||||
def stalled_error_message(state: Mapping[str, Any], now: float) -> str:
|
||||
"""Build a clear, human-facing message for a stalled job, including how long
|
||||
it has been silent and the phase it died in."""
|
||||
last = state.get("last_progress_at") if isinstance(state, Mapping) else None
|
||||
phase = state.get("phase") if isinstance(state, Mapping) else None
|
||||
try:
|
||||
secs = int(float(now) - float(last)) if last else 0
|
||||
except (TypeError, ValueError):
|
||||
secs = 0
|
||||
msg = "Update appears stuck — no progress"
|
||||
if secs > 0:
|
||||
msg += f" for {secs}s"
|
||||
if phase:
|
||||
msg += f" (last phase: {phase})"
|
||||
msg += (". The worker may be hung on the media server. Start a new update "
|
||||
"to try again, or restart SoulSync if it keeps stalling.")
|
||||
return msg
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_STALL_TIMEOUT_SECONDS",
|
||||
"is_db_update_stalled",
|
||||
"stalled_error_message",
|
||||
]
|
||||
|
|
@ -41,19 +41,7 @@ class DatabaseUpdateWorker:
|
|||
self.database_path = database_path
|
||||
self.full_refresh = full_refresh
|
||||
self.should_stop = False
|
||||
|
||||
# Track ids of rows newly INSERTED this run (not updates). The web
|
||||
# layer reads this to gap-fill embedded provider IDs for the new files
|
||||
# (auto-reconcile), so newly-added music contributes its
|
||||
# Spotify/MusicBrainz/etc. ids without a manual backfill.
|
||||
self._new_track_ids = set()
|
||||
|
||||
# Optional callback(worker) run as the FINAL scan phase, immediately
|
||||
# before the 'finished' signal — so the auto-reconcile is inside the
|
||||
# scan's running window (automations/UI treat it as a normal phase and
|
||||
# wait for it). Injected by the web layer (which owns path resolution).
|
||||
self.post_scan_hook = None
|
||||
|
||||
|
||||
# Statistics tracking
|
||||
self.processed_artists = 0
|
||||
self.processed_albums = 0
|
||||
|
|
@ -91,26 +79,7 @@ class DatabaseUpdateWorker:
|
|||
callback(*args)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in callback for {signal_name}: {e}")
|
||||
|
||||
def _emit_finished(self, *args):
|
||||
"""Run the post-scan hook (auto-reconcile) as the final phase, THEN
|
||||
emit 'finished'.
|
||||
|
||||
Running the hook before 'finished' keeps the scan's status at
|
||||
'running' through the reconcile, so every caller (automations that
|
||||
poll for completion, the dashboard card, the Tools page) treats it as
|
||||
a normal scan phase and waits for it — rather than seeing 'finished'
|
||||
and missing the tail. Best-effort: a hook failure never blocks the
|
||||
completion signal.
|
||||
"""
|
||||
if self.post_scan_hook:
|
||||
try:
|
||||
self.post_scan_hook(self)
|
||||
except Exception as e:
|
||||
logger.warning(f"post-scan hook failed (non-fatal): {e}")
|
||||
self._emit_signal('finished', *args)
|
||||
|
||||
|
||||
|
||||
def connect_callback(self, signal_name: str, callback: Callable):
|
||||
"""Connect a callback for progress notifications."""
|
||||
self.callbacks.setdefault(signal_name, []).append(callback)
|
||||
|
|
@ -177,7 +146,7 @@ class DatabaseUpdateWorker:
|
|||
logger.info(f"Merged {merged} duplicate artists")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not merge duplicate artists: {e}")
|
||||
self._emit_finished(0, 0, 0, 0, 0)
|
||||
self._emit_signal('finished', 0, 0, 0, 0, 0)
|
||||
return
|
||||
logger.info(f"Incremental update: Found {len(artists_to_process)} artists to process")
|
||||
|
||||
|
|
@ -261,7 +230,7 @@ class DatabaseUpdateWorker:
|
|||
self.removed_tracks = removal.get('tracks_removed', 0) if removal else 0
|
||||
|
||||
# Emit final results
|
||||
self._emit_finished(
|
||||
self._emit_signal('finished',
|
||||
self.processed_artists,
|
||||
self.processed_albums,
|
||||
self.processed_tracks,
|
||||
|
|
@ -362,7 +331,7 @@ class DatabaseUpdateWorker:
|
|||
f"{self.processed_albums} albums, {self.processed_tracks} new tracks, "
|
||||
f"{stale_removed} stale tracks removed")
|
||||
|
||||
self._emit_finished(
|
||||
self._emit_signal('finished',
|
||||
self.processed_artists,
|
||||
self.processed_albums,
|
||||
self.processed_tracks,
|
||||
|
|
@ -911,8 +880,6 @@ class DatabaseUpdateWorker:
|
|||
track_success = self.database.insert_or_update_media_track(track, album_id, artist_id, server_source=self.server_type)
|
||||
if track_success:
|
||||
total_processed_tracks += 1
|
||||
if track_success == 'inserted':
|
||||
self._new_track_ids.add(str(track.ratingKey))
|
||||
logger.debug(f"Processed new track: {track.title}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
||||
|
|
@ -1377,8 +1344,6 @@ class DatabaseUpdateWorker:
|
|||
skipped_count += 1
|
||||
elif track_success:
|
||||
track_count += 1
|
||||
if track_success == 'inserted':
|
||||
self._new_track_ids.add(track_id_str)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,153 +0,0 @@
|
|||
"""SQLite integrity + safe-backup helpers.
|
||||
|
||||
Born out of a real incident: a WAL-mode DB got corrupted (most likely an
|
||||
interrupted write during a hard restart), and because the backup routine
|
||||
(a) never checked integrity and (b) rotated the oldest backup out by mtime,
|
||||
every rolling backup ended up being a faithful copy of the already-corrupt
|
||||
file — so when recovery was needed, all snapshots were poisoned.
|
||||
|
||||
This module makes that impossible:
|
||||
|
||||
* ``quick_check(path)`` / ``is_healthy(path)`` — fast read-only integrity probe.
|
||||
* ``safe_backup(...)`` — verifies the SOURCE is healthy before copying, uses the
|
||||
SQLite Online Backup API, then verifies the RESULT. A corrupt source never
|
||||
produces (or keeps) a backup.
|
||||
* ``prune_backups(...)`` — rotation that NEVER deletes the most recent
|
||||
*verified-healthy* backup, even to honor the max-count, so a run of bad
|
||||
backups can't evict your last good one.
|
||||
|
||||
Pure-ish: only touches sqlite3 + the filesystem paths it's given; no Flask, no
|
||||
app globals. Unit-testable with real (and deliberately-corrupted) temp DBs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("db_integrity")
|
||||
|
||||
|
||||
def _close_quietly(conn) -> None:
|
||||
"""Best-effort close; a failure to close during cleanup must not mask the
|
||||
real error we're handling, but we log it rather than swallow silently."""
|
||||
if conn is None:
|
||||
return
|
||||
try:
|
||||
conn.close()
|
||||
except Exception as e: # noqa: BLE001 — cleanup path, real error already in flight
|
||||
logger.debug("db_integrity: connection close failed: %s", e)
|
||||
|
||||
|
||||
class DBIntegrityError(Exception):
|
||||
"""Raised when a database fails its integrity check."""
|
||||
|
||||
|
||||
def quick_check(db_path: str, *, timeout: float = 30.0) -> str:
|
||||
"""Run ``PRAGMA quick_check`` read-only and return its first result row.
|
||||
|
||||
Returns ``'ok'`` for a healthy DB, otherwise the first error line. Raises
|
||||
``DBIntegrityError`` if the file can't even be opened/read (malformed
|
||||
header, I/O error) — i.e. unambiguously bad.
|
||||
"""
|
||||
if not os.path.exists(db_path):
|
||||
raise DBIntegrityError(f"Database file not found: {db_path}")
|
||||
conn = None
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=timeout)
|
||||
row = conn.execute("PRAGMA quick_check(1)").fetchone()
|
||||
return (row[0] if row else "no result")
|
||||
except sqlite3.DatabaseError as e:
|
||||
# malformed header / disk image malformed / disk I/O error
|
||||
raise DBIntegrityError(f"{db_path}: {e}") from e
|
||||
finally:
|
||||
_close_quietly(conn)
|
||||
|
||||
|
||||
def is_healthy(db_path: str, *, timeout: float = 30.0) -> bool:
|
||||
"""True iff the DB opens and ``quick_check`` reports 'ok'. Never raises."""
|
||||
try:
|
||||
return quick_check(db_path, timeout=timeout) == "ok"
|
||||
except DBIntegrityError:
|
||||
return False
|
||||
|
||||
|
||||
def safe_backup(src_path: str, dst_path: str, *, verify_source: bool = True,
|
||||
verify_result: bool = True) -> None:
|
||||
"""Back up ``src_path`` to ``dst_path`` via the SQLite Online Backup API,
|
||||
refusing to produce a backup from (or keep a backup of) a corrupt DB.
|
||||
|
||||
Raises ``DBIntegrityError`` and removes any partial ``dst_path`` when the
|
||||
source is unhealthy (``verify_source``) or the produced backup fails its
|
||||
own check (``verify_result``). On success ``dst_path`` is a verified-good
|
||||
copy.
|
||||
"""
|
||||
if verify_source and not is_healthy(src_path):
|
||||
# Don't immortalize corruption — surface it so the caller can alert
|
||||
# and, crucially, NOT rotate out the existing good backups.
|
||||
raise DBIntegrityError(
|
||||
f"Refusing to back up: source database failed integrity check ({src_path})"
|
||||
)
|
||||
|
||||
src = dst = None
|
||||
try:
|
||||
src = sqlite3.connect(src_path)
|
||||
dst = sqlite3.connect(dst_path)
|
||||
src.backup(dst)
|
||||
finally:
|
||||
_close_quietly(dst)
|
||||
_close_quietly(src)
|
||||
|
||||
if verify_result and not is_healthy(dst_path):
|
||||
# The copy itself came out bad — discard it rather than keep a dud.
|
||||
try:
|
||||
os.remove(dst_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise DBIntegrityError(
|
||||
f"Backup produced a corrupt file and was discarded ({dst_path})"
|
||||
)
|
||||
|
||||
|
||||
def prune_backups(backup_paths, max_keep: int,
|
||||
health_check=is_healthy) -> list:
|
||||
"""Decide which backups to delete to honor ``max_keep`` WITHOUT ever
|
||||
deleting the most-recent verified-healthy backup.
|
||||
|
||||
``backup_paths`` is an iterable of paths; order does not matter (we sort by
|
||||
mtime). Returns the list of paths that SHOULD be deleted (does not delete
|
||||
them — the caller does the IO, so this stays pure/testable).
|
||||
|
||||
Rule: oldest-first deletion until <= max_keep, but the single newest
|
||||
*healthy* backup is protected and never selected for deletion. So even if
|
||||
the newest few backups are corrupt, the last good snapshot survives.
|
||||
"""
|
||||
paths = [p for p in backup_paths]
|
||||
# Newest first.
|
||||
paths.sort(key=lambda p: _safe_mtime(p), reverse=True)
|
||||
|
||||
# Find the newest healthy backup — the one we must never drop.
|
||||
protected: Optional[str] = None
|
||||
for p in paths:
|
||||
if health_check(p):
|
||||
protected = p
|
||||
break
|
||||
|
||||
if len(paths) <= max_keep:
|
||||
return []
|
||||
|
||||
# Delete oldest-first beyond max_keep, but skip the protected one.
|
||||
deletable = [p for p in paths if p != protected]
|
||||
# oldest first among deletable
|
||||
deletable.sort(key=lambda p: _safe_mtime(p))
|
||||
num_to_delete = len(paths) - max_keep
|
||||
return deletable[:num_to_delete]
|
||||
|
||||
|
||||
def _safe_mtime(path: str) -> float:
|
||||
try:
|
||||
return os.path.getmtime(path)
|
||||
except OSError:
|
||||
return 0.0
|
||||
|
|
@ -6,7 +6,6 @@ from typing import Dict, List, Optional, Any
|
|||
from functools import wraps
|
||||
from dataclasses import dataclass
|
||||
from utils.logging_config import get_logger
|
||||
from core.metadata.artist_album_cache import get_cached_artist_album_items, store_artist_album_items
|
||||
from core.metadata.cache import get_metadata_cache
|
||||
|
||||
logger = get_logger("deezer_client")
|
||||
|
|
@ -117,48 +116,6 @@ def _is_full_track_payload(payload: Optional[Dict[str, Any]]) -> bool:
|
|||
return 'track_position' in payload and 'contributors' in payload
|
||||
|
||||
|
||||
def resolve_album_track_positions(session, base_url, album_ids, cache=None, sleep_s=0.2):
|
||||
"""Build ``{str(track_id): track_position}`` for a set of Deezer album ids.
|
||||
|
||||
Deezer PLAYLIST and SEARCH track objects (and even the album object's embedded
|
||||
``tracks.data``) omit ``track_position`` — only ``/album/<id>/tracks`` and
|
||||
``/track/<id>`` carry it. So numbering playlist tracks by their playlist index
|
||||
silently poisons the real album track number, which then rides onto the
|
||||
downloaded file's tag. This resolves the authoritative position per album
|
||||
(cache-first, best-effort — a failed album just isn't in the map)."""
|
||||
import time as _time
|
||||
positions: Dict[str, int] = {}
|
||||
for aid in album_ids:
|
||||
aid = str(aid)
|
||||
at_list = None
|
||||
if cache:
|
||||
try:
|
||||
ct = cache.get_entity('deezer', 'album_tracks', aid)
|
||||
if ct and ct.get('data'):
|
||||
at_list = ct['data']
|
||||
except Exception: # noqa: BLE001 - cache is best-effort
|
||||
at_list = None
|
||||
if at_list is None:
|
||||
try:
|
||||
if sleep_s:
|
||||
_time.sleep(sleep_s) # respect Deezer rate limits
|
||||
r = session.get(f"{base_url}/album/{aid}/tracks", params={'limit': 500}, timeout=10)
|
||||
if getattr(r, 'ok', False):
|
||||
at_list = (r.json() or {}).get('data', [])
|
||||
if cache and at_list is not None:
|
||||
try:
|
||||
cache.store_entity('deezer', 'album_tracks', aid, {'data': at_list})
|
||||
except Exception as _cache_err: # noqa: BLE001
|
||||
logger.debug("album_tracks cache store failed for %s: %s", aid, _cache_err)
|
||||
except Exception: # noqa: BLE001 - never let metadata resolution break the fetch
|
||||
at_list = None
|
||||
for at in (at_list or []):
|
||||
tp = at.get('track_position')
|
||||
if at.get('id') and tp:
|
||||
positions[str(at['id'])] = tp
|
||||
return positions
|
||||
|
||||
|
||||
# ==================== Dataclasses (match iTunesClient / SpotifyClient format) ====================
|
||||
|
||||
@dataclass
|
||||
|
|
@ -916,41 +873,17 @@ class DeezerClient:
|
|||
|
||||
Matches iTunesClient.get_artist_albums() interface.
|
||||
Paginates through all results up to the requested limit."""
|
||||
cache = get_metadata_cache()
|
||||
cached_items = get_cached_artist_album_items(cache, 'deezer', artist_id, album_type=album_type, limit=limit)
|
||||
if cached_items:
|
||||
try:
|
||||
requested_types = [t.strip() for t in album_type.split(',')]
|
||||
cached_albums = []
|
||||
for album_data in cached_items:
|
||||
album = Album.from_deezer_album(album_data)
|
||||
if album_type != 'album,single':
|
||||
if album.album_type not in requested_types:
|
||||
if not (album.album_type == 'ep' and 'single' in requested_types):
|
||||
continue
|
||||
cached_albums.append(album)
|
||||
return cached_albums[:limit]
|
||||
except Exception as e:
|
||||
logger.debug("Deezer artist albums cache reuse failed: %s", e)
|
||||
|
||||
albums = []
|
||||
all_raw = []
|
||||
requested_types = [t.strip() for t in album_type.split(',')]
|
||||
offset = 0
|
||||
page_size = 100 # Deezer API max per request
|
||||
complete = True # cleared if pagination breaks on a transient/malformed error
|
||||
|
||||
while offset < limit:
|
||||
fetch_limit = min(page_size, limit - offset)
|
||||
data = self._api_get(f'artist/{artist_id}/albums', {'limit': fetch_limit, 'index': offset})
|
||||
if not data or 'data' not in data:
|
||||
# Malformed/transient response mid-pagination — what we have is a
|
||||
# PARTIAL discography. Don't cache it as the full list (mirrors the
|
||||
# Spotify truncated-fetch guard). #853 follow-up.
|
||||
complete = False
|
||||
if not data or 'data' not in data or len(data['data']) == 0:
|
||||
break
|
||||
if len(data['data']) == 0:
|
||||
break # No more albums — a clean end of pagination.
|
||||
|
||||
for album_data in data['data']:
|
||||
all_raw.append(album_data)
|
||||
|
|
@ -967,6 +900,7 @@ class DeezerClient:
|
|||
break # Last page
|
||||
offset += len(data['data'])
|
||||
|
||||
cache = get_metadata_cache()
|
||||
# Deezer's /artist/{id}/albums endpoint doesn't include artist info on each album.
|
||||
# Inject it so cached album entities have artist_name for discover page display.
|
||||
artist_stub = None
|
||||
|
|
@ -980,11 +914,6 @@ class DeezerClient:
|
|||
entries.append((str(ad['id']), ad))
|
||||
if entries:
|
||||
cache.store_entities_bulk('deezer', 'album', entries, skip_if_exists=True)
|
||||
# Only cache the artist→album-LIST when pagination finished cleanly; a
|
||||
# partial list would otherwise serve an incomplete discography until TTL.
|
||||
# (Individual album entities above are complete, so they cache regardless.)
|
||||
if complete:
|
||||
store_artist_album_items(cache, 'deezer', artist_id, all_raw, album_type=album_type, limit=limit)
|
||||
|
||||
logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}")
|
||||
return albums[:limit]
|
||||
|
|
@ -1399,16 +1328,6 @@ class DeezerClient:
|
|||
|
||||
raw_tracks.extend(page_tracks)
|
||||
|
||||
# Real album track positions — playlist tracks don't carry track_position,
|
||||
# so numbering by playlist index would poison the downloaded file's tag.
|
||||
album_ids = {str(t.get('album', {}).get('id')) for t in raw_tracks if t.get('album', {}).get('id')}
|
||||
try:
|
||||
from core.metadata.cache import get_metadata_cache
|
||||
_cache = get_metadata_cache()
|
||||
except Exception:
|
||||
_cache = None
|
||||
track_positions = resolve_album_track_positions(self.session, self.BASE_URL, album_ids, _cache)
|
||||
|
||||
# Normalize tracks
|
||||
tracks: List[Dict[str, Any]] = []
|
||||
for i, t in enumerate(raw_tracks, start=1):
|
||||
|
|
@ -1420,8 +1339,7 @@ class DeezerClient:
|
|||
'artists': [artist_name],
|
||||
'album': t.get('album', {}).get('title', ''),
|
||||
'duration_ms': t.get('duration', 0) * 1000,
|
||||
# REAL album position; the playlist index is a last resort only.
|
||||
'track_number': track_positions.get(str(t.get('id'))) or i,
|
||||
'track_number': i,
|
||||
})
|
||||
|
||||
result = {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ from typing import Any, Dict, List, Optional, Tuple
|
|||
import requests
|
||||
|
||||
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
|
||||
from core.quality.source_map import quality_from_deezer, quality_tier_for_source
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("deezer_download")
|
||||
|
|
@ -93,10 +92,7 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
|||
if download_path is None:
|
||||
download_path = config_manager.get('soulseek.download_path', './downloads')
|
||||
self.download_path = Path(download_path)
|
||||
try:
|
||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
logger.warning(f"Could not verify download path {self.download_path}: {e}")
|
||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Engine reference is populated by set_engine() at registration
|
||||
# time. None until orchestrator wires the registry.
|
||||
|
|
@ -121,20 +117,14 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
|||
self._license_token = None
|
||||
self._user_data = None
|
||||
self._authenticated = False
|
||||
self._pending_arl: Optional[str] = None
|
||||
|
||||
# Quality preference
|
||||
self._quality = quality_tier_for_source('deezer', default='flac')
|
||||
self._quality = config_manager.get('deezer_download.quality', 'flac')
|
||||
|
||||
# Try to authenticate on init if ARL is configured
|
||||
arl = config_manager.get('deezer_download.arl', '')
|
||||
if arl:
|
||||
from core.boot_phase import is_boot_phase
|
||||
if is_boot_phase():
|
||||
self._pending_arl = arl
|
||||
logger.debug("Deezer ARL present — authentication deferred until after boot")
|
||||
else:
|
||||
self._authenticate(arl)
|
||||
self._authenticate(arl)
|
||||
|
||||
logger.info(f"Deezer download client initialized (download path: {self.download_path})")
|
||||
|
||||
|
|
@ -233,66 +223,12 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
|||
return self._authenticated
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
if self._pending_arl and not self._authenticated:
|
||||
from core.boot_phase import is_boot_phase
|
||||
if not is_boot_phase():
|
||||
self._authenticate(self._pending_arl)
|
||||
self._pending_arl = None
|
||||
return self._authenticated
|
||||
|
||||
async def check_connection(self) -> bool:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self.is_available)
|
||||
|
||||
# ─── Playlist export (#945) ──────────────────────────────────
|
||||
#
|
||||
# UNOFFICIAL: rides the private gw-light gateway with the ARL session already used
|
||||
# for downloads. Deezer shut their public developer API, so this is the only write
|
||||
# path — and it's fragile by nature (breaks when Deezer changes internals).
|
||||
|
||||
def create_or_update_playlist(self, name, track_ids, *, existing_id=None,
|
||||
public=False, description=""):
|
||||
"""Create a Deezer playlist (or append to an existing one) from a mirrored
|
||||
playlist's tracks. ``track_ids`` are stored ``deezer_id`` values per library track.
|
||||
``existing_id`` set → add to that playlist (idempotent re-export reuses the stored
|
||||
target); unset → create a new one. Returns
|
||||
``{success, playlist_id, url, added, error}``."""
|
||||
if not self._authenticated:
|
||||
return {"success": False, "error": "Deezer is not connected (ARL)"}
|
||||
song_ids = [str(t) for t in (track_ids or []) if t]
|
||||
if not song_ids:
|
||||
return {"success": False, "error": "No matching Deezer tracks to export"}
|
||||
try:
|
||||
songs = [[sid, i] for i, sid in enumerate(song_ids)]
|
||||
if existing_id:
|
||||
res = self._gw_call("playlist.addSongs",
|
||||
{"playlist_id": int(existing_id), "songs": songs})
|
||||
if res is None:
|
||||
return {"success": False, "error": "Deezer rejected the playlist update"}
|
||||
playlist_id = existing_id
|
||||
else:
|
||||
res = self._gw_call("playlist.create", {
|
||||
"title": name, "description": description,
|
||||
"is_public": bool(public), "songs": songs,
|
||||
})
|
||||
if res is None:
|
||||
return {"success": False, "error": "Deezer rejected the playlist create"}
|
||||
# gw 'playlist.create' returns the new playlist id (int) as `results`.
|
||||
if isinstance(res, dict):
|
||||
playlist_id = res.get("PLAYLIST_ID") or res.get("id")
|
||||
else:
|
||||
playlist_id = res
|
||||
if not playlist_id:
|
||||
return {"success": False, "error": "Deezer did not return a playlist id"}
|
||||
return {
|
||||
"success": True,
|
||||
"playlist_id": str(playlist_id),
|
||||
"url": f"https://www.deezer.com/playlist/{playlist_id}",
|
||||
"added": len(song_ids),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def reconnect(self, arl: str = None) -> bool:
|
||||
"""Re-authenticate with a new or existing ARL."""
|
||||
if arl is None:
|
||||
|
|
@ -481,12 +417,6 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
|||
if aid:
|
||||
album_ids.add(str(aid))
|
||||
album_release_dates = {}
|
||||
# Deezer PLAYLIST tracks do NOT carry `track_position` (only `/track/<id>`
|
||||
# and `/album/<id>/tracks` do), so numbering them by their playlist index
|
||||
# poisons the real album track number — which then rides into the wishlist
|
||||
# and onto the downloaded file's tag (e.g. 'Apologize' tagged track 1 instead
|
||||
# of 16). Resolve the REAL position from each album's track list (cache-first).
|
||||
track_positions: Dict[str, int] = {} # str(track_id) -> album track_position
|
||||
try:
|
||||
from core.metadata.cache import get_metadata_cache
|
||||
cache = get_metadata_cache()
|
||||
|
|
@ -499,32 +429,24 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
|||
cached = cache.get_entity('deezer', 'album', aid)
|
||||
if cached and cached.get('release_date'):
|
||||
album_release_dates[aid] = cached['release_date']
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug("cache get_entity album release_date: %s", e)
|
||||
# Cache miss — fetch from API
|
||||
if aid not in album_release_dates:
|
||||
try:
|
||||
time.sleep(0.3) # Respect rate limits
|
||||
a_resp = self._session.get(f'https://api.deezer.com/album/{aid}', timeout=10)
|
||||
if a_resp.ok:
|
||||
a_data = a_resp.json()
|
||||
album_release_dates[aid] = a_data.get('release_date', '')
|
||||
# Store in metadata cache for future use
|
||||
if cache:
|
||||
try:
|
||||
cache.store_entity('deezer', 'album', aid, a_data)
|
||||
except Exception as e:
|
||||
logger.debug("cache store_entity album release_date: %s", e)
|
||||
except Exception as e:
|
||||
logger.debug("fetch deezer album release_date %s: %s", aid, e)
|
||||
# Real album track positions (separate endpoint — playlist tracks AND the
|
||||
# album object's embedded tracks both omit track_position). Cache-first.
|
||||
try:
|
||||
from core.deezer_client import resolve_album_track_positions
|
||||
track_positions = resolve_album_track_positions(
|
||||
self._session, 'https://api.deezer.com', album_ids, cache)
|
||||
except Exception as e:
|
||||
logger.debug("resolve deezer album track positions: %s", e)
|
||||
try:
|
||||
time.sleep(0.3) # Respect rate limits
|
||||
a_resp = self._session.get(f'https://api.deezer.com/album/{aid}', timeout=10)
|
||||
if a_resp.ok:
|
||||
a_data = a_resp.json()
|
||||
album_release_dates[aid] = a_data.get('release_date', '')
|
||||
# Store in metadata cache for future use
|
||||
if cache:
|
||||
try:
|
||||
cache.store_entity('deezer', 'album', aid, a_data)
|
||||
except Exception as e:
|
||||
logger.debug("cache store_entity album release_date: %s", e)
|
||||
except Exception as e:
|
||||
logger.debug("fetch deezer album release_date %s: %s", aid, e)
|
||||
|
||||
tracks = []
|
||||
for i, t in enumerate(raw_tracks, start=1):
|
||||
|
|
@ -545,9 +467,7 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
|||
'id': album_id,
|
||||
},
|
||||
'duration_ms': t.get('duration', 0) * 1000,
|
||||
# REAL album position (resolved above); the playlist index is a last
|
||||
# resort only when the album lookup failed, never the default.
|
||||
'track_number': track_positions.get(str(t.get('id'))) or t.get('track_position') or i,
|
||||
'track_number': i,
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
@ -661,7 +581,7 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
|||
bitrate = 128
|
||||
quality = 'mp3'
|
||||
|
||||
tr = TrackResult(
|
||||
results.append(TrackResult(
|
||||
username='deezer_dl',
|
||||
filename=f"{track_id}||{artist} - {title}",
|
||||
size=est_size,
|
||||
|
|
@ -675,10 +595,7 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
|||
title=title,
|
||||
album=album,
|
||||
track_number=item.get('track_position'),
|
||||
)
|
||||
# Stamp CD-quality FLAC (16/44.1) so lossless ranks correctly.
|
||||
tr.set_quality(quality_from_deezer(self._quality))
|
||||
results.append(tr)
|
||||
))
|
||||
|
||||
logger.info(f"Deezer search for '{query}' returned {len(results)} results")
|
||||
return results, []
|
||||
|
|
|
|||
|
|
@ -8,15 +8,7 @@ from datetime import datetime, timedelta
|
|||
from utils.logging_config import get_logger
|
||||
from database.music_database import MusicDatabase
|
||||
from core.deezer_client import DeezerClient
|
||||
from core.worker_utils import (
|
||||
accept_artist_match,
|
||||
artist_name_matches,
|
||||
interruptible_sleep,
|
||||
owned_album_titles,
|
||||
pick_artist_by_catalog,
|
||||
release_titles,
|
||||
set_album_api_track_count,
|
||||
)
|
||||
from core.worker_utils import interruptible_sleep, set_album_api_track_count
|
||||
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||
|
||||
logger = get_logger("deezer_worker")
|
||||
|
|
@ -171,16 +163,6 @@ class DeezerWorker:
|
|||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Pinned-group override (Manage Enrichment Workers): process one
|
||||
# entity type first, then fall through to the normal chain. Unset or
|
||||
# exhausted ⇒ default artist→album→track order, unchanged.
|
||||
from core.worker_utils import read_enrichment_priority, priority_pending_item
|
||||
_prio = read_enrichment_priority('deezer')
|
||||
if _prio:
|
||||
_pi = priority_pending_item(cursor, 'deezer', _prio)
|
||||
if _pi:
|
||||
return _pi
|
||||
|
||||
# Priority 1: Unattempted artists
|
||||
cursor.execute("""
|
||||
SELECT id, name
|
||||
|
|
@ -286,19 +268,10 @@ class DeezerWorker:
|
|||
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
|
||||
return similarity >= self.name_similarity_threshold
|
||||
|
||||
def _verify_artist_id(self, item: Dict[str, Any], result_artist_id,
|
||||
result_artist_name: Optional[str] = None) -> bool:
|
||||
def _verify_artist_id(self, item: Dict[str, Any], result_artist_id) -> bool:
|
||||
"""Verify that the result's artist ID matches the parent artist's stored Deezer ID.
|
||||
|
||||
If mismatched, the album/track search is more specific (uses artist+title),
|
||||
so we trust it and correct the parent artist's deezer_id — BUT only when
|
||||
the result's artist *name* actually matches our parent artist. Without
|
||||
that guard, a collaboration or compilation track (e.g. a track our
|
||||
library credits to Jorja Smith that lives on Kendrick Lamar's curated
|
||||
"Black Panther" album) would search up to an album whose Deezer primary
|
||||
artist is someone else (Kendrick), and we'd stamp that wrong Deezer ID
|
||||
onto our artist — corrupting it (and causing duplicate ids shared across
|
||||
unrelated artists)."""
|
||||
so we trust it and correct the parent artist's deezer_id."""
|
||||
parent_deezer_id = item.get('artist_deezer_id')
|
||||
if not parent_deezer_id:
|
||||
return True
|
||||
|
|
@ -307,20 +280,6 @@ class DeezerWorker:
|
|||
return True
|
||||
|
||||
if str(result_artist_id) != str(parent_deezer_id):
|
||||
# Guard: only correct when the album/track's primary artist is the
|
||||
# SAME artist by name. A mismatch means it's a collab/compilation,
|
||||
# not a stale-id correction.
|
||||
parent_name = item.get('artist') or ''
|
||||
if (result_artist_name and parent_name
|
||||
and not self._name_matches(parent_name, result_artist_name)):
|
||||
logger.info(
|
||||
f"Skipping artist-ID correction from {item['type']} "
|
||||
f"'{item['name']}': result artist '{result_artist_name}' "
|
||||
f"≠ parent '{parent_name}' (collab/compilation, not a "
|
||||
f"correction)"
|
||||
)
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
f"Artist ID correction from {item['type']} '{item['name']}': "
|
||||
f"updating parent artist Deezer ID from {parent_deezer_id} to {result_artist_id}"
|
||||
|
|
@ -409,34 +368,17 @@ class DeezerWorker:
|
|||
logger.debug(f"Preserving existing Deezer ID for artist '{artist_name}': {existing_id}")
|
||||
return
|
||||
|
||||
# Multi-candidate search (was single search_artist) so same-name artists
|
||||
# can be disambiguated: gate by name, then pick the one whose catalog
|
||||
# overlaps the albums this library owns.
|
||||
results = self.client.search_artists(artist_name, limit=5)
|
||||
gated = [a for a in (results or []) if artist_name_matches(artist_name, getattr(a, 'name', ''))]
|
||||
chosen, _overlap = pick_artist_by_catalog(
|
||||
gated,
|
||||
owned_album_titles(self.db, artist_id),
|
||||
lambda a: release_titles(self.client.get_artist_albums_list(a.id)),
|
||||
)
|
||||
|
||||
# search_artists returns lean Artist objects; fetch the full dict (same
|
||||
# shape the old search_artist returned) for storage.
|
||||
result = self.client.get_artist_info(chosen.id) if chosen else None
|
||||
result = self.client.search_artist(artist_name)
|
||||
if result:
|
||||
result_name = result.get('name', '')
|
||||
ok, reason = accept_artist_match(
|
||||
self.db, 'deezer_id', result.get('id'), artist_id,
|
||||
artist_name, result_name,
|
||||
)
|
||||
if ok:
|
||||
if self._name_matches(artist_name, result_name):
|
||||
self._update_artist(artist_id, result)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched artist '{artist_name}' -> Deezer ID: {result.get('id')}")
|
||||
else:
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Artist '{artist_name}' not matched: {reason}")
|
||||
logger.debug(f"Name mismatch for artist '{artist_name}' (got '{result_name}')")
|
||||
else:
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
|
|
@ -478,8 +420,7 @@ class DeezerWorker:
|
|||
# Verify artist ID
|
||||
result_artist = result.get('artist', {})
|
||||
result_artist_id = result_artist.get('id') if result_artist else None
|
||||
result_artist_name = result_artist.get('name') if result_artist else None
|
||||
self._verify_artist_id(item, result_artist_id, result_artist_name)
|
||||
self._verify_artist_id(item, result_artist_id)
|
||||
|
||||
# Fetch full album details for label, genres, explicit
|
||||
deezer_album_id = result.get('id')
|
||||
|
|
@ -530,8 +471,7 @@ class DeezerWorker:
|
|||
# Verify artist ID
|
||||
result_artist = result.get('artist', {})
|
||||
result_artist_id = result_artist.get('id') if result_artist else None
|
||||
result_artist_name = result_artist.get('name') if result_artist else None
|
||||
self._verify_artist_id(item, result_artist_id, result_artist_name)
|
||||
self._verify_artist_id(item, result_artist_id)
|
||||
|
||||
# Fetch full track details for BPM
|
||||
deezer_track_id = result.get('id')
|
||||
|
|
|
|||
|
|
@ -1,136 +0,0 @@
|
|||
"""On-demand memory-growth diagnostic (issue #802: ~0.7 MiB/s RSS growth).
|
||||
|
||||
Wraps ``tracemalloc`` so a user seeing runaway memory can capture WHERE the
|
||||
allocations come from instead of us guessing:
|
||||
|
||||
1. start_tracking() — begins tracing + stores a baseline snapshot
|
||||
2. ...reproduce the growth for a few minutes...
|
||||
3. report() — top allocation sites, with the DELTA since baseline
|
||||
(the delta is the leak; absolute sizes are mostly
|
||||
startup noise)
|
||||
4. stop_tracking() — ends tracing, frees trace memory
|
||||
|
||||
Opt-in by design: tracemalloc costs CPU and memory while active (it shadows
|
||||
every allocation), so it must never run by default. The Flask endpoints that
|
||||
expose this live in web_server (GET /api/debug/memory/...) so a user can drive
|
||||
the whole flow from a browser.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import tracemalloc
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("diagnostics.memory")
|
||||
|
||||
_baseline: Optional[tracemalloc.Snapshot] = None
|
||||
_started_at: Optional[float] = None
|
||||
|
||||
# Allocation-site traces this deep give useful "who called it" context without
|
||||
# pathological overhead.
|
||||
_TRACE_FRAMES = 15
|
||||
|
||||
|
||||
def is_tracking() -> bool:
|
||||
return tracemalloc.is_tracing()
|
||||
|
||||
|
||||
def start_tracking() -> Dict[str, Any]:
|
||||
"""Begin tracing and store the baseline snapshot. Idempotent."""
|
||||
global _baseline, _started_at
|
||||
if tracemalloc.is_tracing():
|
||||
return {"tracking": True, "already_running": True, "started_at": _started_at}
|
||||
tracemalloc.start(_TRACE_FRAMES)
|
||||
_baseline = tracemalloc.take_snapshot()
|
||||
_started_at = time.time()
|
||||
logger.info("Memory tracking started (tracemalloc, %d frames)", _TRACE_FRAMES)
|
||||
return {"tracking": True, "already_running": False, "started_at": _started_at}
|
||||
|
||||
|
||||
def stop_tracking() -> Dict[str, Any]:
|
||||
"""End tracing and free the trace bookkeeping."""
|
||||
global _baseline, _started_at
|
||||
was = tracemalloc.is_tracing()
|
||||
if was:
|
||||
tracemalloc.stop()
|
||||
logger.info("Memory tracking stopped")
|
||||
_baseline = None
|
||||
_started_at = None
|
||||
return {"tracking": False, "was_tracking": was}
|
||||
|
||||
|
||||
def _rss_mb() -> Optional[float]:
|
||||
"""Process RSS in MiB, best-effort (psutil, then /proc fallback)."""
|
||||
try:
|
||||
import psutil
|
||||
return round(psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024), 1)
|
||||
except Exception: # noqa: S110 — RSS is optional context; fall through to /proc
|
||||
pass
|
||||
try:
|
||||
with open("/proc/self/status", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
if line.startswith("VmRSS:"):
|
||||
return round(int(line.split()[1]) / 1024, 1)
|
||||
except Exception: # noqa: S110 — no /proc on this platform; RSS stays None
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def format_stat(stat: Any) -> Dict[str, Any]:
|
||||
"""Project one tracemalloc StatisticDiff/Statistic into a plain dict.
|
||||
Duck-typed (reads size/count/size_diff/count_diff/traceback) so it's
|
||||
unit-testable without real snapshots."""
|
||||
tb = getattr(stat, "traceback", None)
|
||||
frames: List[str] = []
|
||||
if tb:
|
||||
# Most-recent-call-last reads naturally top-down in a report.
|
||||
for frame in list(tb)[-3:]:
|
||||
frames.append(f"{frame.filename}:{frame.lineno}")
|
||||
return {
|
||||
"location": frames[-1] if frames else "?",
|
||||
"trace": frames,
|
||||
"size_mb": round(getattr(stat, "size", 0) / (1024 * 1024), 3),
|
||||
"size_diff_mb": round(getattr(stat, "size_diff", 0) / (1024 * 1024), 3),
|
||||
"count": getattr(stat, "count", 0),
|
||||
"count_diff": getattr(stat, "count_diff", 0),
|
||||
}
|
||||
|
||||
|
||||
def report(top: int = 25) -> Dict[str, Any]:
|
||||
"""Current snapshot vs the start_tracking() baseline: the top allocation
|
||||
sites by GROWTH (size_diff). Includes traced totals + process RSS so the
|
||||
user can see how much of the real growth tracemalloc accounts for."""
|
||||
if not tracemalloc.is_tracing():
|
||||
return {
|
||||
"tracking": False,
|
||||
"rss_mb": _rss_mb(),
|
||||
"hint": "Start with /api/debug/memory/start, reproduce the growth "
|
||||
"for a few minutes, then call this again.",
|
||||
}
|
||||
snapshot = tracemalloc.take_snapshot()
|
||||
# Filter the tracer's own bookkeeping out of the picture.
|
||||
snapshot = snapshot.filter_traces((
|
||||
tracemalloc.Filter(False, tracemalloc.__file__),
|
||||
tracemalloc.Filter(False, "<frozen importlib._bootstrap>"),
|
||||
))
|
||||
current, peak = tracemalloc.get_traced_memory()
|
||||
|
||||
if _baseline is not None:
|
||||
stats = snapshot.compare_to(_baseline, "traceback")
|
||||
stats.sort(key=lambda s: s.size_diff, reverse=True)
|
||||
else:
|
||||
stats = snapshot.statistics("traceback")
|
||||
|
||||
return {
|
||||
"tracking": True,
|
||||
"started_at": _started_at,
|
||||
"elapsed_seconds": round(time.time() - _started_at, 1) if _started_at else None,
|
||||
"traced_current_mb": round(current / (1024 * 1024), 1),
|
||||
"traced_peak_mb": round(peak / (1024 * 1024), 1),
|
||||
"rss_mb": _rss_mb(),
|
||||
"top_growth": [format_stat(s) for s in stats[:top]],
|
||||
}
|
||||
|
|
@ -12,7 +12,6 @@ import re
|
|||
import time
|
||||
import threading
|
||||
import requests
|
||||
from core.metadata.artist_album_cache import get_cached_artist_album_payload, store_artist_album_items
|
||||
from core.metadata.cache import get_metadata_cache
|
||||
from typing import List, Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -80,51 +79,6 @@ def _clean_discogs_artist_name(name: Optional[str]) -> str:
|
|||
return _DISCOGS_DISAMBIG_RE.sub('', name).strip()
|
||||
|
||||
|
||||
# --- Discogs album ID typing -------------------------------------------------
|
||||
# Discogs has two album object types — masters (/masters/{id}) and releases
|
||||
# (/releases/{id}) — whose numeric IDs share one space, so release N and master
|
||||
# N are DIFFERENT albums. A bare numeric ID is therefore ambiguous. We tag the
|
||||
# type into the ID string ('m12345' / 'r12345') at the point we parse it, so the
|
||||
# correct endpoint can be chosen later without guessing. (Artist IDs are a single
|
||||
# namespace and stay untagged.)
|
||||
|
||||
def _discogs_album_kind(data: Dict[str, Any]) -> str:
|
||||
"""Classify a Discogs album payload as 'master' or 'release'.
|
||||
|
||||
Search results and artist-discography items carry an explicit ``type``;
|
||||
full detail responses don't, but only master detail has ``main_release``."""
|
||||
t = (data.get('type') or '').lower()
|
||||
if t in ('master', 'release'):
|
||||
return t
|
||||
return 'master' if 'main_release' in data else 'release'
|
||||
|
||||
|
||||
def _tag_discogs_album_id(raw_id: Any, kind: str) -> str:
|
||||
"""``'12345'`` + ``'master'`` -> ``'m12345'``; empty input -> ``''``."""
|
||||
s = str(raw_id or '').strip()
|
||||
if not s:
|
||||
return ''
|
||||
return f"{'m' if kind == 'master' else 'r'}{s}"
|
||||
|
||||
|
||||
def _discogs_album_endpoints(album_id: Any) -> List[str]:
|
||||
"""Map a (possibly tagged) album ID to the API path(s) to try, in order.
|
||||
|
||||
``'m12345'`` -> ``['/masters/12345']``
|
||||
``'r12345'`` -> ``['/releases/12345']``
|
||||
``'12345'`` (legacy untagged) -> ``['/releases/12345', '/masters/12345']``
|
||||
|
||||
Legacy bare IDs are tried release-first because stored IDs originate
|
||||
overwhelmingly from search / manual-match / collection sync (all releases);
|
||||
this also self-heals pre-fix bad matches. Returns ``[]`` for unusable input."""
|
||||
s = str(album_id or '').strip()
|
||||
if len(s) > 1 and s[0] in ('m', 'r') and s[1:].isdigit():
|
||||
return [f"/{'masters' if s[0] == 'm' else 'releases'}/{s[1:]}"]
|
||||
if s.isdigit():
|
||||
return [f'/releases/{s}', f'/masters/{s}']
|
||||
return []
|
||||
|
||||
|
||||
# --- Shared dataclasses (same shape as iTunes/Deezer/Spotify) ---
|
||||
|
||||
@dataclass
|
||||
|
|
@ -350,7 +304,7 @@ class Album:
|
|||
external_urls['discogs_api'] = release_data['resource_url']
|
||||
|
||||
return cls(
|
||||
id=_tag_discogs_album_id(release_data.get('id', ''), _discogs_album_kind(release_data)),
|
||||
id=str(release_data.get('id', '')),
|
||||
name=title,
|
||||
artists=artists,
|
||||
release_date=release_date,
|
||||
|
|
@ -689,13 +643,10 @@ class DiscogsClient:
|
|||
if cached and cached.get('title'):
|
||||
data = cached
|
||||
else:
|
||||
# Hit the endpoint that matches the ID's type (tag-driven, no guessing).
|
||||
data = None
|
||||
for path in _discogs_album_endpoints(release_id):
|
||||
data = self._api_get(path)
|
||||
if data and data.get('title'):
|
||||
break
|
||||
data = None
|
||||
# Try as master first (artist discography returns master IDs)
|
||||
data = self._api_get(f'/masters/{release_id}')
|
||||
if not data or not data.get('title'):
|
||||
data = self._api_get(f'/releases/{release_id}')
|
||||
if not data:
|
||||
return None
|
||||
cache.store_entity('discogs', 'album', release_id, data)
|
||||
|
|
@ -729,45 +680,26 @@ class DiscogsClient:
|
|||
|
||||
def get_artist_albums(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> List[Album]:
|
||||
"""Get releases by an artist. Prefers master releases, filters features."""
|
||||
cache = get_metadata_cache()
|
||||
cached_payload = get_cached_artist_album_payload(cache, 'discogs', artist_id, album_type=album_type, limit=limit)
|
||||
releases = cached_payload.get('_releases') if cached_payload else None
|
||||
artist_name = ''
|
||||
if cached_payload:
|
||||
artist_name = str(cached_payload.get('artist_name') or '').lower()
|
||||
# First get the artist name for feature filtering. Strip Discogs
|
||||
# disambiguation suffix so feature-vs-primary matching below
|
||||
# compares against the canonical name, not "Beyoncé*".
|
||||
artist_data = self._api_get(f'/artists/{artist_id}')
|
||||
artist_name = _clean_discogs_artist_name(
|
||||
artist_data.get('name', '') if artist_data else ''
|
||||
).lower()
|
||||
|
||||
if not isinstance(releases, list) or not releases:
|
||||
# First get the artist name for feature filtering. Strip Discogs
|
||||
# disambiguation suffix so feature-vs-primary matching below
|
||||
# compares against the canonical name, not "Beyoncé*".
|
||||
artist_data = self._api_get(f'/artists/{artist_id}')
|
||||
artist_name = _clean_discogs_artist_name(
|
||||
artist_data.get('name', '') if artist_data else ''
|
||||
).lower()
|
||||
|
||||
data = self._api_get(f'/artists/{artist_id}/releases', {
|
||||
'sort': 'year', 'sort_order': 'desc', 'per_page': min(limit * 3, 200),
|
||||
})
|
||||
if not data or not data.get('releases'):
|
||||
return []
|
||||
releases = data.get('releases') or []
|
||||
store_artist_album_items(
|
||||
cache,
|
||||
'discogs',
|
||||
artist_id,
|
||||
releases,
|
||||
album_type=album_type,
|
||||
limit=limit,
|
||||
items_field='_releases',
|
||||
extra_fields={'artist_name': artist_name},
|
||||
)
|
||||
data = self._api_get(f'/artists/{artist_id}/releases', {
|
||||
'sort': 'year', 'sort_order': 'desc', 'per_page': min(limit * 3, 200),
|
||||
})
|
||||
if not data or not data.get('releases'):
|
||||
return []
|
||||
|
||||
# Separate masters from individual releases — prefer masters (canonical versions)
|
||||
masters = []
|
||||
releases_no_master = []
|
||||
master_titles = set()
|
||||
|
||||
for item in releases:
|
||||
for item in data['releases']:
|
||||
# Skip non-main roles
|
||||
role = item.get('role', 'Main').lower()
|
||||
if role not in ('main', ''):
|
||||
|
|
@ -835,13 +767,10 @@ class DiscogsClient:
|
|||
if cached:
|
||||
return cached
|
||||
|
||||
# Hit the endpoint that matches the ID's type (tag-driven, no guessing).
|
||||
data = None
|
||||
for path in _discogs_album_endpoints(release_id):
|
||||
data = self._api_get(path)
|
||||
if data and data.get('tracklist'):
|
||||
break
|
||||
data = None
|
||||
# Try as master first (master IDs are used in artist discography)
|
||||
data = self._api_get(f'/masters/{release_id}')
|
||||
if not data or not data.get('tracklist'):
|
||||
data = self._api_get(f'/releases/{release_id}')
|
||||
if not data or not data.get('tracklist'):
|
||||
return None
|
||||
|
||||
|
|
@ -853,7 +782,7 @@ class DiscogsClient:
|
|||
image_url = (primary or images[0]).get('uri')
|
||||
|
||||
album_info = {
|
||||
'id': str(release_id),
|
||||
'id': str(data.get('id', release_id)),
|
||||
'name': data.get('title', ''),
|
||||
'images': [{'url': image_url, 'height': 600, 'width': 600}] if image_url else [],
|
||||
'release_date': str(data.get('year', '')) if data.get('year') else '',
|
||||
|
|
@ -942,13 +871,9 @@ class DiscogsClient:
|
|||
cached = cache.get_entity('discogs', 'album', str(release_id))
|
||||
if cached and cached.get('title'):
|
||||
return cached
|
||||
# Hit the endpoint that matches the ID's type (tag-driven, no guessing).
|
||||
data = None
|
||||
for path in _discogs_album_endpoints(release_id):
|
||||
data = self._api_get(path)
|
||||
if data and data.get('title'):
|
||||
break
|
||||
data = None
|
||||
data = self._api_get(f'/masters/{release_id}')
|
||||
if not data or not data.get('title'):
|
||||
data = self._api_get(f'/releases/{release_id}')
|
||||
if data:
|
||||
cache.store_entity('discogs', 'album', str(release_id), data)
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ from typing import Optional, Dict, Any
|
|||
from datetime import datetime, timedelta
|
||||
from utils.logging_config import get_logger
|
||||
from database.music_database import MusicDatabase
|
||||
from core.discogs_client import DiscogsClient, _discogs_album_kind, _tag_discogs_album_id
|
||||
from core.worker_utils import accept_artist_match, interruptible_sleep, set_album_api_track_count
|
||||
from core.discogs_client import DiscogsClient
|
||||
from core.worker_utils import interruptible_sleep, set_album_api_track_count
|
||||
|
||||
logger = get_logger("discogs_worker")
|
||||
|
||||
|
|
@ -174,16 +174,6 @@ class DiscogsWorker:
|
|||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Pinned-group override (Manage Enrichment Workers): process one
|
||||
# entity type first, then fall through to the normal chain. Discogs
|
||||
# has no track endpoint, so only artist/album are honored.
|
||||
from core.worker_utils import read_enrichment_priority, priority_pending_item
|
||||
_prio = read_enrichment_priority('discogs')
|
||||
if _prio in ('artist', 'album'):
|
||||
_pi = priority_pending_item(cursor, 'discogs', _prio)
|
||||
if _pi:
|
||||
return _pi
|
||||
|
||||
# Priority 1: Unattempted artists
|
||||
cursor.execute("""
|
||||
SELECT id, name FROM artists
|
||||
|
|
@ -332,13 +322,9 @@ class DiscogsWorker:
|
|||
self.stats['not_found'] += 1
|
||||
return
|
||||
|
||||
# Find best match by name similarity (skipping ids already claimed by
|
||||
# a differently-named artist, so we don't create a shared/duplicate id).
|
||||
# Find best match by name similarity
|
||||
for result in results:
|
||||
ok, reason = accept_artist_match(
|
||||
self.db, 'discogs_id', result.id, artist_id, artist_name, result.name,
|
||||
)
|
||||
if ok:
|
||||
if self._name_matches(artist_name, result.name):
|
||||
# Fetch full artist detail (uses cache)
|
||||
data = self.client._fetch_and_cache_artist(result.id)
|
||||
if data:
|
||||
|
|
@ -436,9 +422,7 @@ class DiscogsWorker:
|
|||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Tag the ID with its Discogs type so later re-fetches hit the right
|
||||
# endpoint (master vs release share one numeric space).
|
||||
discogs_id = _tag_discogs_album_id(data.get('id', ''), _discogs_album_kind(data))
|
||||
discogs_id = str(data.get('id', ''))
|
||||
genres = json.dumps(data.get('genres', []))
|
||||
styles = json.dumps(data.get('styles', []))
|
||||
labels = data.get('labels', [])
|
||||
|
|
|
|||
|
|
@ -104,13 +104,7 @@ def cancel_sync(
|
|||
"""
|
||||
try:
|
||||
if key not in states:
|
||||
# Idempotent: the live discovery state is gone (a restart wiped the
|
||||
# in-memory state, or it was already cancelled). Cancelling a sync
|
||||
# that isn't running is a no-op SUCCESS, not a 404 — otherwise a
|
||||
# mirrored playlist (e.g. a ListenBrainz weekly) whose state vanished
|
||||
# is permanently wedged with "playlist not found" and can never be
|
||||
# re-synced or dismissed (#702).
|
||||
return {"success": True, "message": f"No active {label} sync to cancel"}, 200
|
||||
return {"error": not_found_message}, 404
|
||||
|
||||
state = states[key]
|
||||
state['last_accessed'] = time.time()
|
||||
|
|
@ -585,68 +579,45 @@ def update_discovery_match(
|
|||
return {'error': 'Missing required fields'}, 400
|
||||
|
||||
state = states.get(identifier)
|
||||
result = None
|
||||
if state:
|
||||
if track_index >= len(state['discovery_results']):
|
||||
return {'error': 'Invalid track index'}, 400
|
||||
if not state:
|
||||
return {'error': 'Discovery state not found'}, 404
|
||||
|
||||
result = state['discovery_results'][track_index]
|
||||
old_status = result.get('status')
|
||||
if track_index >= len(state['discovery_results']):
|
||||
return {'error': 'Invalid track index'}, 400
|
||||
|
||||
result['status'] = 'Found'
|
||||
result['status_class'] = 'found'
|
||||
result['spotify_track'] = spotify_track['name']
|
||||
result['spotify_artist'] = join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else extract_artist_name(spotify_track['artists'])
|
||||
result['spotify_album'] = spotify_track['album']
|
||||
result['spotify_id'] = spotify_track['id']
|
||||
result = state['discovery_results'][track_index]
|
||||
old_status = result.get('status')
|
||||
|
||||
duration_ms = spotify_track.get('duration_ms', 0)
|
||||
if duration_ms:
|
||||
minutes = duration_ms // 60000
|
||||
seconds = (duration_ms % 60000) // 1000
|
||||
result['duration'] = f"{minutes}:{seconds:02d}"
|
||||
else:
|
||||
result['duration'] = '0:00'
|
||||
result['status'] = 'Found'
|
||||
result['status_class'] = 'found'
|
||||
result['spotify_track'] = spotify_track['name']
|
||||
result['spotify_artist'] = join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else extract_artist_name(spotify_track['artists'])
|
||||
result['spotify_album'] = spotify_track['album']
|
||||
result['spotify_id'] = spotify_track['id']
|
||||
|
||||
result['spotify_data'] = build_fix_modal_spotify_data(spotify_track)
|
||||
result['wing_it_fallback'] = False
|
||||
result['manual_match'] = True
|
||||
duration_ms = spotify_track.get('duration_ms', 0)
|
||||
if duration_ms:
|
||||
minutes = duration_ms // 60000
|
||||
seconds = (duration_ms % 60000) // 1000
|
||||
result['duration'] = f"{minutes}:{seconds:02d}"
|
||||
else:
|
||||
result['duration'] = '0:00'
|
||||
|
||||
if old_status != 'found' and old_status != 'Found':
|
||||
state['spotify_matches'] = state.get('spotify_matches', 0) + 1
|
||||
result['spotify_data'] = build_fix_modal_spotify_data(spotify_track)
|
||||
result['wing_it_fallback'] = False
|
||||
result['manual_match'] = True
|
||||
|
||||
logger.info(f"Manual match updated: {source_log_label} - {identifier} - track {track_index}")
|
||||
logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}")
|
||||
if old_status != 'found' and old_status != 'Found':
|
||||
state['spotify_matches'] = state.get('spotify_matches', 0) + 1
|
||||
|
||||
logger.info(f"Manual match updated: {source_log_label} - {identifier} - track {track_index}")
|
||||
logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}")
|
||||
|
||||
try:
|
||||
original_track = result.get(original_track_key, {})
|
||||
original_name = original_track.get('name', spotify_track['name'])
|
||||
original_artist = original_artist_getter(original_track)
|
||||
else:
|
||||
# #843: the in-memory discovery state can be gone — a server restart,
|
||||
# or an imported playlist that wasn't discovered in THIS process —
|
||||
# while the card is still shown from persisted data. The DURABLE part
|
||||
# of a manual fix (writing the match to the discovery cache so future
|
||||
# syncs resolve it) doesn't need the in-memory state, only the original
|
||||
# track's name + artist, which the client now sends. Fall back to those
|
||||
# instead of 404ing the fix into uselessness.
|
||||
original_name = (data.get('original_name') or '').strip()
|
||||
original_artist = (data.get('original_artist') or '').strip()
|
||||
if not original_name and not original_artist:
|
||||
return {'error': 'Discovery state not found'}, 404
|
||||
if not original_name:
|
||||
original_name = spotify_track['name']
|
||||
# Key the cache by the FIRST artist — every in-memory + sync path uses
|
||||
# artists[0], but the client may send a joined "A, B, C" string. Without
|
||||
# this, a multi-artist track would save under a key the sync never looks
|
||||
# up (full string ≠ first artist), so the fix would silently never apply.
|
||||
if original_artist:
|
||||
original_artist = original_artist.split(',')[0].strip()
|
||||
logger.info(
|
||||
f"Manual match (no in-memory state) → discovery cache: "
|
||||
f"{source_log_label} - {identifier} - '{original_name}' by '{original_artist}'"
|
||||
)
|
||||
|
||||
try:
|
||||
cache_key = get_discovery_cache_key(original_name, original_artist)
|
||||
artists_list = spotify_track['artists']
|
||||
if isinstance(artists_list, list):
|
||||
|
|
|
|||
|
|
@ -1,340 +0,0 @@
|
|||
"""Listening-driven recommendation core (#913).
|
||||
|
||||
PURE, side-effect-free ranking that turns "the artists you listen to most" plus
|
||||
"who's similar to each" into:
|
||||
|
||||
1. a consensus-ranked list of artists you'd probably love but don't own, and
|
||||
2. an aggregated candidate-track list for a generated playlist.
|
||||
|
||||
No DB / network / config here. The caller (the watchlist scanner) supplies the
|
||||
seeds (top-played artists), the ``similar_artists`` rows per seed, and the
|
||||
owned-artist set, then fetches top tracks for the winners. Keeping the decision
|
||||
logic in one pure place makes it fully unit-testable without the live stack and
|
||||
keeps the scan wiring thin — and additive, so it can't disturb existing flows.
|
||||
|
||||
Scoring rationale (the "best in class" bit): a recommended artist's score is
|
||||
``Σ over the seeds that recommend it of (seed_weight × similarity)``. That single
|
||||
sum rewards all three signals at once — **consensus** (an artist endorsed by many
|
||||
of your seeds accumulates more terms), your **play weight** (heavier seeds push
|
||||
harder), and **similarity strength** — instead of a flat "appears in N lists".
|
||||
``seed_count`` is exposed separately for display ("because you like A, B, C") and
|
||||
as the adventurousness dial's lever (``min_seed_count``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Sequence, Set
|
||||
|
||||
|
||||
def _norm(name: object) -> str:
|
||||
return str(name or "").strip().lower()
|
||||
|
||||
|
||||
def _positive_float(value: object, default: float = 1.0) -> float:
|
||||
try:
|
||||
f = float(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return f if f > 0 else default
|
||||
|
||||
|
||||
def _get(row: object, attr: str):
|
||||
"""Read a field from a dataclass row or a dict row."""
|
||||
if isinstance(row, dict):
|
||||
return row.get(attr)
|
||||
return getattr(row, attr, None)
|
||||
|
||||
|
||||
def choose_mix_fetch_source(active_source: object, active_can_fetch: bool) -> str:
|
||||
"""Pick which source to fetch the "Listening Mix" top tracks from.
|
||||
|
||||
The mix is a list of (artist, title) pairs acquired via Soulseek, so the fetch source need
|
||||
NOT match the user's active metadata source. Use the active source when it can fetch top
|
||||
tracks itself (Spotify/Deezer); otherwise fall back to Deezer, whose public ``artist/{id}/top``
|
||||
needs no auth and is available to every user — so iTunes / Discogs / MusicBrainz users still
|
||||
get a full mix without switching sources. Pure.
|
||||
"""
|
||||
if str(active_source or "").lower() in ("spotify", "deezer") and active_can_fetch:
|
||||
return str(active_source).lower()
|
||||
return "deezer"
|
||||
|
||||
|
||||
def names_match(a: object, b: object) -> bool:
|
||||
"""Strict artist-name equality after stripping case + non-alphanumerics.
|
||||
|
||||
Used to verify a name-search result before fetching that artist's top tracks, so the
|
||||
"Listening Mix" can never pull the WRONG artist's songs (e.g. a same-name act). Exact
|
||||
alphanumeric match: "Tyler, The Creator" == "Tyler The Creator", but "Drake" != "Drake Bell".
|
||||
Pure.
|
||||
"""
|
||||
def _alnum(x: object) -> str:
|
||||
return "".join(ch for ch in str(x or "").lower() if ch.isalnum())
|
||||
na, nb = _alnum(a), _alnum(b)
|
||||
return bool(na) and na == nb
|
||||
|
||||
|
||||
def similarity_from_rank(rank: object, max_rank: int = 10) -> float:
|
||||
"""Turn a stored ``similarity_rank`` (1 = most similar … 10 = least) into a 0–1 weight.
|
||||
|
||||
SoulSync stores each ``(seed → similar)`` edge with a 1–10 rank (``1`` is the closest
|
||||
match). The ranker multiplies this into the score so a seed's *closest* matches count
|
||||
for more than its long-tail ones. Linear decay over the documented range: rank 1 → 1.0,
|
||||
rank 5 → 0.6, rank 10 → 0.1, with a 0.1 floor so a far match still contributes. A
|
||||
missing/garbage rank falls back to 1.0 (treat as "no rank info, full weight"). Pure.
|
||||
"""
|
||||
try:
|
||||
r = int(rank)
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
floor = round(1.0 / max_rank, 4)
|
||||
if r <= 1:
|
||||
return 1.0
|
||||
if r >= max_rank:
|
||||
return floor
|
||||
return round((max_rank - r + 1) / max_rank, 4)
|
||||
|
||||
|
||||
def build_recency_weighted_seeds(
|
||||
top_artists: Sequence[dict],
|
||||
recent_play_counts: Optional[Dict[str, float]] = None,
|
||||
*,
|
||||
recency_factor: float = 1.5,
|
||||
) -> List[dict]:
|
||||
"""Blend lifetime + recent play counts into seed weights — "what you're into NOW".
|
||||
|
||||
``weight = lifetime_plays + recency_factor × recent_plays``. An artist you've played a
|
||||
lot *recently* outranks one you played a lot years ago, so the recommendations track
|
||||
your current taste instead of your all-time history. ``recency_factor`` is the dial
|
||||
(0 = pure lifetime). Returns ``[{'name', 'weight'}]`` for :func:`rank_recommended_artists`.
|
||||
Pure — the caller supplies both play-count maps from the listening history.
|
||||
"""
|
||||
recent = {_norm(k): _positive_float(v, 0.0) for k, v in (recent_play_counts or {}).items()}
|
||||
out: List[dict] = []
|
||||
for a in top_artists or ():
|
||||
name = str(a.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
lifetime = _positive_float(a.get("play_count", a.get("weight", 1.0)))
|
||||
boost = recency_factor * recent.get(_norm(name), 0.0)
|
||||
out.append({"name": name, "weight": lifetime + boost})
|
||||
return out
|
||||
|
||||
|
||||
def group_similars_by_seed(
|
||||
seeds: Sequence[dict],
|
||||
similar_rows: Sequence,
|
||||
id_to_name: Dict[str, str],
|
||||
*,
|
||||
source_id_attr: str = "source_artist_id",
|
||||
similar_name_attr: str = "similar_artist_name",
|
||||
rank_attr: Optional[str] = None,
|
||||
) -> Dict[str, List[dict]]:
|
||||
"""Reshape flat ``similar_artists`` rows into ``{seed_name_lower: [{'name', 'score'?}]}``.
|
||||
|
||||
The stored rows key the similar artist by the SEED's source id (``source_artist_id``),
|
||||
not its name, so :func:`rank_recommended_artists` can't consume them directly. This
|
||||
resolves each row's source id to a name via ``id_to_name`` (``{source_artist_id:
|
||||
artist_name}`` for the library, built by the caller) and keeps only rows that resolve
|
||||
to one of the ``seeds``. Rows may be dataclass objects or dicts. Pure — no I/O.
|
||||
|
||||
``id_to_name`` MUST be keyed by whatever id the edges actually store — for SoulSync that
|
||||
is the artist's SOURCE id (Spotify/iTunes/Deezer/MusicBrainz), NOT the internal row id.
|
||||
When ``rank_attr`` is given, each row's rank is converted via :func:`similarity_from_rank`
|
||||
and carried as ``score`` so closer matches weigh more; without it every similar comes out
|
||||
score-less (the ranker then treats similarity as 1.0 — original behavior).
|
||||
"""
|
||||
seed_names = {_norm(s.get("name")) for s in seeds}
|
||||
seed_names.discard("")
|
||||
id_to_norm = {str(k): _norm(v) for k, v in (id_to_name or {}).items()}
|
||||
|
||||
out: Dict[str, List[dict]] = {}
|
||||
for row in similar_rows or ():
|
||||
seed_name = id_to_norm.get(str(_get(row, source_id_attr) or ""), "")
|
||||
if not seed_name or seed_name not in seed_names:
|
||||
continue
|
||||
sim_name = str(_get(row, similar_name_attr) or "").strip()
|
||||
if not sim_name:
|
||||
continue
|
||||
entry = {"name": sim_name}
|
||||
if rank_attr is not None:
|
||||
entry["score"] = similarity_from_rank(_get(row, rank_attr))
|
||||
out.setdefault(seed_name, []).append(entry)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecommendedArtist:
|
||||
"""One artist recommended from your listening, with the why."""
|
||||
name: str # display name (first-seen casing)
|
||||
score: float # Σ seed_weight × similarity
|
||||
seed_count: int # distinct seeds endorsing it (consensus)
|
||||
seeds: List[str] = field(default_factory=list) # display names of those seeds
|
||||
|
||||
|
||||
def rank_recommended_artists(
|
||||
seeds: Sequence[dict],
|
||||
similars_by_seed: Dict[str, Sequence[dict]],
|
||||
owned_artist_names: Optional[Set[str]] = None,
|
||||
*,
|
||||
limit: int = 30,
|
||||
min_seed_count: int = 1,
|
||||
) -> List[RecommendedArtist]:
|
||||
"""Rank artists similar to your most-played by consensus + play weight + similarity.
|
||||
|
||||
Args:
|
||||
seeds: ``[{'name': str, 'weight': float}]`` — your top-played artists.
|
||||
``weight`` (play count or any positive number) defaults to 1.0.
|
||||
similars_by_seed: ``{seed_name_lower: [{'name': str, 'score': float}]}`` — the
|
||||
similar-artist rows for each seed. ``score`` is optional (defaults 1.0).
|
||||
owned_artist_names: lowercased names already in the library — excluded so the
|
||||
result is artists you DON'T have. The seeds themselves are always excluded.
|
||||
limit: max results.
|
||||
min_seed_count: drop recommendations endorsed by fewer than N seeds — the
|
||||
adventurousness dial's "Safer" end raises this for higher-confidence picks.
|
||||
|
||||
Returns up to ``limit`` :class:`RecommendedArtist`, highest score first.
|
||||
"""
|
||||
owned = {_norm(a) for a in (owned_artist_names or set())}
|
||||
seed_norms = {_norm(s.get("name")) for s in seeds}
|
||||
seed_norms.discard("")
|
||||
exclude = owned | seed_norms
|
||||
|
||||
acc: Dict[str, dict] = {}
|
||||
for seed in seeds:
|
||||
s_name = _norm(seed.get("name"))
|
||||
if not s_name:
|
||||
continue
|
||||
s_display = str(seed.get("name") or "").strip()
|
||||
weight = _positive_float(seed.get("weight", 1.0))
|
||||
for sim in similars_by_seed.get(s_name, ()) or ():
|
||||
a_norm = _norm(sim.get("name"))
|
||||
if not a_norm or a_norm in exclude:
|
||||
continue
|
||||
sim_score = _positive_float(sim.get("score", 1.0))
|
||||
row = acc.setdefault(
|
||||
a_norm, {"name": str(sim.get("name") or "").strip(), "score": 0.0, "seeds": {}}
|
||||
)
|
||||
row["score"] += weight * sim_score
|
||||
row["seeds"].setdefault(s_name, s_display) # one seed counts once
|
||||
|
||||
out: List[RecommendedArtist] = []
|
||||
floor = max(1, int(min_seed_count))
|
||||
for row in acc.values():
|
||||
seed_count = len(row["seeds"])
|
||||
if seed_count < floor:
|
||||
continue
|
||||
out.append(RecommendedArtist(
|
||||
name=row["name"],
|
||||
score=round(row["score"], 6),
|
||||
seed_count=seed_count,
|
||||
seeds=list(row["seeds"].values()),
|
||||
))
|
||||
out.sort(key=lambda r: (-r.score, -r.seed_count, r.name.lower()))
|
||||
return out[:limit]
|
||||
|
||||
|
||||
def aggregate_candidate_tracks(
|
||||
recommended_artists: Sequence[RecommendedArtist],
|
||||
top_tracks_by_artist: Dict[str, Sequence[dict]],
|
||||
owned_track_keys: Optional[Set] = None,
|
||||
*,
|
||||
per_artist: int = 3,
|
||||
limit: int = 50,
|
||||
exclude_owned: bool = True,
|
||||
) -> List[dict]:
|
||||
"""Build the candidate track list for the generated playlist.
|
||||
|
||||
Takes the top ``per_artist`` tracks from each recommended artist **in artist-rank
|
||||
order**, dedups by ``(artist, title)``, optionally drops owned tracks (the
|
||||
"discovery" flavor) and caps at ``limit``. Each returned track dict is the source
|
||||
track plus ``_seed_artist`` (which recommended artist it came from).
|
||||
|
||||
Args:
|
||||
recommended_artists: ranked output of :func:`rank_recommended_artists`.
|
||||
top_tracks_by_artist: ``{artist_name_lower: [track_dict, ...]}`` — fetched by
|
||||
the caller (Last.fm / source top tracks), NOT limited to a curated pool.
|
||||
owned_track_keys: set of ``(artist_lower, title_lower)`` already in the library.
|
||||
exclude_owned: drop tracks in ``owned_track_keys`` (discovery flavor). Set False
|
||||
for a "replay" playlist of tracks you already own.
|
||||
"""
|
||||
owned = owned_track_keys or set()
|
||||
seen: Set = set()
|
||||
out: List[dict] = []
|
||||
for art in recommended_artists:
|
||||
tracks = top_tracks_by_artist.get(_norm(art.name), ()) or ()
|
||||
taken = 0
|
||||
for t in tracks:
|
||||
if taken >= per_artist:
|
||||
break
|
||||
title = str(t.get("name") or t.get("title") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
key = (_norm(art.name), _norm(title))
|
||||
if key in seen:
|
||||
continue
|
||||
if exclude_owned and key in owned:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append({**t, "_seed_artist": art.name})
|
||||
taken += 1
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out[:limit]
|
||||
|
||||
|
||||
def to_mix_track(track: object, source: str) -> Optional[dict]:
|
||||
"""Shape one source "top tracks" API dict into the flat dict the Discover compact
|
||||
playlist row renders + syncs (the "Listening Mix" #913 playlist).
|
||||
|
||||
Spotify's ``artist_top_tracks`` and Deezer's ``get_artist_top_tracks`` both return the
|
||||
same Spotify-shape object (``id, name, artists[], album{name,images[]}, duration_ms``).
|
||||
This flattens that into the renderer's field names (``track_name/artist_name/album_name/
|
||||
album_cover_url/duration_ms``), keeps the original under ``track_data_json`` for sync, and
|
||||
sets the source-specific id field. Returns None for anything without a usable id/title so
|
||||
the caller can filter. A ``name`` key is kept so :func:`aggregate_candidate_tracks` can
|
||||
dedup by title. Pure — no I/O.
|
||||
"""
|
||||
if not isinstance(track, dict):
|
||||
return None
|
||||
tid = track.get("id")
|
||||
name = str(track.get("name") or "").strip()
|
||||
if not tid or not name:
|
||||
return None
|
||||
artists = track.get("artists") or []
|
||||
artist_name = ""
|
||||
if artists and isinstance(artists[0], dict):
|
||||
artist_name = str(artists[0].get("name") or "").strip()
|
||||
album = track.get("album") if isinstance(track.get("album"), dict) else {}
|
||||
album_name = str(album.get("name") or "").strip()
|
||||
images = album.get("images") or []
|
||||
cover = images[0].get("url") if images and isinstance(images[0], dict) else None
|
||||
out = {
|
||||
"track_id": str(tid),
|
||||
"name": name, # for aggregate_candidate_tracks dedup
|
||||
"track_name": name, # for the renderer
|
||||
"artist_name": artist_name,
|
||||
"album_name": album_name,
|
||||
"album_cover_url": cover,
|
||||
"duration_ms": track.get("duration_ms") or 0,
|
||||
"track_data_json": track, # full payload for sync/download
|
||||
"source": source,
|
||||
}
|
||||
id_field = {"spotify": "spotify_track_id", "deezer": "deezer_track_id",
|
||||
"itunes": "itunes_track_id"}.get(source)
|
||||
if id_field:
|
||||
out[id_field] = str(tid)
|
||||
return out
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RecommendedArtist",
|
||||
"choose_mix_fetch_source",
|
||||
"names_match",
|
||||
"similarity_from_rank",
|
||||
"build_recency_weighted_seeds",
|
||||
"to_mix_track",
|
||||
"group_similars_by_seed",
|
||||
"rank_recommended_artists",
|
||||
"aggregate_candidate_tracks",
|
||||
]
|
||||
|
|
@ -16,10 +16,6 @@ to test in isolation:
|
|||
overwrites the user's deliberate pick with whatever the auto-search
|
||||
ranks first, so manual matches are exempt regardless of provider
|
||||
drift. `is_drifted_for_redo` encapsulates the decision.
|
||||
|
||||
3. *Should the Playlist Pipeline pre-scan (re)discover this track at all?*
|
||||
— `should_rediscover` encapsulates that gate, with the manual match
|
||||
checked FIRST so a leftover Wing It flag can't override the user's pick.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -72,49 +68,3 @@ def is_drifted_for_redo(
|
|||
return False
|
||||
cached_provider = extra_data.get('provider', 'spotify')
|
||||
return cached_provider != active_provider
|
||||
|
||||
|
||||
def should_rediscover(extra_data: Optional[Dict[str, Any]]) -> bool:
|
||||
"""Return True when a mirrored track needs (re)discovery, False to skip it.
|
||||
|
||||
This is the gate the Playlist Pipeline pre-scan runs over every mirrored
|
||||
track before discovering. The **ordering is the fix**: a manual match is
|
||||
authoritative and is checked FIRST.
|
||||
|
||||
``extra_data`` is *merged* on save (see ``update_mirrored_track_extra_data``),
|
||||
so a track that was a Wing It stub and is then manually fixed still carries
|
||||
``wing_it_fallback: True`` alongside the new ``manual_match: True``. The old
|
||||
pre-scan tested ``wing_it_fallback`` before ``manual_match``, so the stale
|
||||
flag won and the pipeline re-discovered the track — silently reverting the
|
||||
user's pick to Wing It. Checking ``manual_match`` first makes the fix stick.
|
||||
|
||||
Decision order:
|
||||
* manual_match -> skip (authoritative; never re-discover)
|
||||
* wing_it_fallback -> redo (stub — keep trying for a real match)
|
||||
* discovered + complete -> skip (full metadata already stored)
|
||||
* discovered + incomplete -> redo (backfill track_number / album fields)
|
||||
* unmatched_by_user -> skip (user deliberately removed the match)
|
||||
* never discovered -> redo (first-time discovery)
|
||||
"""
|
||||
extra = extra_data if isinstance(extra_data, dict) else {}
|
||||
|
||||
if extra.get('discovered'):
|
||||
if extra.get('manual_match'):
|
||||
return False
|
||||
if extra.get('wing_it_fallback'):
|
||||
return True
|
||||
# Otherwise re-discover only when the stored match is missing the
|
||||
# enriched fields (track_number + release_date/album.id) that older
|
||||
# discoveries dropped via the Track dataclass.
|
||||
matched = extra.get('matched_data')
|
||||
matched = matched if isinstance(matched, dict) else {}
|
||||
album = matched.get('album')
|
||||
album = album if isinstance(album, dict) else {}
|
||||
has_track_num = matched.get('track_number')
|
||||
has_release = album.get('release_date')
|
||||
has_album_id = album.get('id')
|
||||
return not (has_track_num and (has_release or has_album_id))
|
||||
|
||||
if extra.get('unmatched_by_user'):
|
||||
return False
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -37,35 +37,9 @@ import time
|
|||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
from core.discovery.manual_match import should_rediscover
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _canonical_best_score(deps, title, artist, duration_ms, results):
|
||||
"""Score search results against the source track, trying the canonicalized
|
||||
title/artist too and keeping the better confidence (#785).
|
||||
|
||||
YouTube playlists have their "Artist - Title" / channel decoration stripped
|
||||
at ingest, but file/CSV-imported playlists keep raw titles — so a track
|
||||
titled "Arctic Monkeys - Do I Wanna Know?" scored verbatim against the
|
||||
library's "Do I Wanna Know?" never matched. canonical_source_track is
|
||||
conservative (only strips an "<artist> - " prefix when it equals the
|
||||
artist), so this can only ADD a better candidate, never weaken a match.
|
||||
Returns (match, confidence)."""
|
||||
match, confidence, _ = deps.discovery_score_candidates(title, artist, duration_ms, results)
|
||||
try:
|
||||
from core.text.source_title import canonical_source_track
|
||||
canon_title, canon_artist = canonical_source_track(title or '', artist or '')
|
||||
except Exception:
|
||||
return match, confidence
|
||||
if (canon_title, canon_artist) != (title, artist):
|
||||
alt_match, alt_conf, _ = deps.discovery_score_candidates(canon_title, canon_artist, duration_ms, results)
|
||||
if alt_match and alt_conf > confidence:
|
||||
return alt_match, alt_conf
|
||||
return match, confidence
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistDiscoveryDeps:
|
||||
"""Bundle of cross-cutting deps the playlist discovery worker needs."""
|
||||
|
|
@ -147,14 +121,44 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
|||
existing_extra = json.loads(track['extra_data']) if isinstance(track['extra_data'], str) else track['extra_data']
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
# `should_rediscover` is the single source of truth for this
|
||||
# gate (manual match checked FIRST so a stale Wing It flag can't
|
||||
# revert a user's deliberate fix — see its docstring).
|
||||
if should_rediscover(existing_extra):
|
||||
undiscovered_tracks.append(track)
|
||||
else:
|
||||
if existing_extra.get('discovered'):
|
||||
if existing_extra.get('wing_it_fallback'):
|
||||
# Wing It stub — always re-attempt to find a real match
|
||||
undiscovered_tracks.append(track)
|
||||
elif existing_extra.get('manual_match'):
|
||||
# User explicitly picked this match via the Fix popup.
|
||||
# Manual fixes are authoritative: they may lack
|
||||
# track_number / album.id / release_date (the Fix-popup
|
||||
# save shape is intentionally lean — search-result rows
|
||||
# don't include track_number, and the MBID-lookup flat
|
||||
# shape doesn't carry album.id), but re-running discovery
|
||||
# against the active source would overwrite the user's
|
||||
# deliberate pick with whatever the auto-search ranks
|
||||
# first. Skip — pipeline only re-discovers when the user
|
||||
# has cleared the match.
|
||||
pl_skipped += 1
|
||||
total_skipped += 1
|
||||
else:
|
||||
# Check if matched_data is complete — old discoveries may be missing
|
||||
# track_number/release_date due to the Track dataclass stripping them.
|
||||
# Re-discover these so the enriched pipeline fills in the gaps.
|
||||
md = existing_extra.get('matched_data', {})
|
||||
album = md.get('album', {})
|
||||
has_track_num = md.get('track_number')
|
||||
has_release = album.get('release_date') if isinstance(album, dict) else None
|
||||
has_album_id = album.get('id') if isinstance(album, dict) else None
|
||||
if has_track_num and (has_release or has_album_id):
|
||||
pl_skipped += 1
|
||||
total_skipped += 1
|
||||
else:
|
||||
# Incomplete discovery — re-discover to get full metadata
|
||||
undiscovered_tracks.append(track)
|
||||
elif existing_extra.get('unmatched_by_user'):
|
||||
# User explicitly removed this match — respect their choice
|
||||
pl_skipped += 1
|
||||
total_skipped += 1
|
||||
else:
|
||||
undiscovered_tracks.append(track)
|
||||
|
||||
if pl_skipped > 0:
|
||||
deps.update_automation_progress(automation_id,
|
||||
|
|
@ -219,20 +223,6 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
|||
except Exception:
|
||||
search_queries = [f"{artist_name} {track_name}", track_name]
|
||||
|
||||
# #785: file/CSV playlists keep raw "Artist - Title" titles, so the
|
||||
# queries above search for the artist prefix too. Also search the
|
||||
# canonicalized title so the right candidates are actually returned
|
||||
# (the scorer best-of then matches them).
|
||||
try:
|
||||
from core.text.source_title import canonical_source_track
|
||||
_cq_title, _cq_artist = canonical_source_track(track_name, artist_name)
|
||||
if (_cq_title, _cq_artist) != (track_name, artist_name):
|
||||
for _q in (f"{_cq_artist} {_cq_title}", _cq_title):
|
||||
if _q not in search_queries:
|
||||
search_queries.append(_q)
|
||||
except Exception as _cq_err:
|
||||
logger.debug("canonical search-query add failed: %s", _cq_err)
|
||||
|
||||
# Step 3: Search and score
|
||||
best_match = None
|
||||
best_confidence = 0.0
|
||||
|
|
@ -247,8 +237,8 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
|||
if not results:
|
||||
continue
|
||||
|
||||
match, confidence = _canonical_best_score(
|
||||
deps, track_name, artist_name, duration_ms, results
|
||||
match, confidence, _ = deps.discovery_score_candidates(
|
||||
track_name, artist_name, duration_ms, results
|
||||
)
|
||||
|
||||
if match and confidence > best_confidence:
|
||||
|
|
@ -269,8 +259,8 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
|||
else:
|
||||
extended = itunes_client_instance.search_tracks(query, limit=50)
|
||||
if extended:
|
||||
match, confidence = _canonical_best_score(
|
||||
deps, track_name, artist_name, duration_ms, extended
|
||||
match, confidence, _ = deps.discovery_score_candidates(
|
||||
track_name, artist_name, duration_ms, extended
|
||||
)
|
||||
if match and confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
|
|
|
|||
|
|
@ -1,31 +1,44 @@
|
|||
"""Shared metadata match + result-normalization helpers for quality matching.
|
||||
"""Background worker for the library quality scanner.
|
||||
|
||||
These were the matching guts of the old auto-acting quality-scanner worker (now
|
||||
removed — quality scanning is the ``quality_upgrade`` library-maintenance repair
|
||||
job in ``core/repair_jobs/quality_upgrade.py``). They're kept here as a single
|
||||
source of truth and imported by that job:
|
||||
`run_quality_scanner(scope, profile_id, deps)` is the function the
|
||||
quality-scanner endpoint kicks off in a thread to scan the library
|
||||
for low-quality tracks (below the user's configured quality profile)
|
||||
and add provider matches to the wishlist:
|
||||
|
||||
- ``_search_tracks_for_source`` — query one metadata source's ``search_tracks``.
|
||||
- ``_normalize_track_match`` / ``_normalize_track_album`` / ``_normalize_track_artists``
|
||||
— turn a provider track into the wishlist-ready dict (typed Album converters
|
||||
with legacy duck-typed fallback).
|
||||
- ``_track_name`` / ``_track_artist_names`` / ``_extract_lookup_value`` — accessors.
|
||||
1. Reset scanner state, load quality profile + minimum acceptable tier.
|
||||
2. Load tracks from DB based on scope:
|
||||
- 'watchlist' → tracks for watchlisted artists only.
|
||||
- other → all library tracks.
|
||||
3. For each track:
|
||||
- Stop-request gate (state['status'] != 'running').
|
||||
- Quality-tier check via _get_quality_tier_from_extension(file_path).
|
||||
- Skip tracks meeting standards (tier_num <= min_acceptable_tier).
|
||||
- For low-quality tracks: matching_engine search query gen, score
|
||||
candidates against the configured metadata source priority
|
||||
(artist + title similarity, album-type bonus), pick best match >=
|
||||
0.7 confidence.
|
||||
- On match: add normalized track data to wishlist via
|
||||
`wishlist_service.add_track_to_wishlist` with
|
||||
source_type='quality_scanner' and a source_context that captures
|
||||
original file_path, format tier, bitrate, and match confidence.
|
||||
4. After all tracks: status='finished', progress=100, activity feed
|
||||
entry, emit `quality_scan_completed` event for automation engine.
|
||||
5. On critical exception: status='error', error message captured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from core.metadata.registry import get_client_for_source
|
||||
from core.metadata.registry import get_client_for_source, get_primary_source, get_source_priority
|
||||
from core.metadata.types import Album
|
||||
from core.wishlist.payloads import ensure_wishlist_track_format
|
||||
|
||||
# Use the project logger namespace ("soulsync.*") so the scanner's progress and
|
||||
# diagnostics actually surface in the app log — plain getLogger(__name__) lands
|
||||
# under "core.discovery.quality_scanner", which the app log view doesn't show.
|
||||
from utils.logging_config import get_logger
|
||||
logger = get_logger("discovery.quality_scanner")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Per-source typed converter dispatch — same registry pattern as
|
||||
|
|
@ -43,6 +56,16 @@ _TYPED_ALBUM_CONVERTERS: Dict[str, Callable[[Dict[str, Any]], Album]] = {
|
|||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualityScannerDeps:
|
||||
"""Bundle of cross-cutting deps the quality scanner needs."""
|
||||
quality_scanner_state: dict
|
||||
quality_scanner_lock: Any # threading.Lock
|
||||
QUALITY_TIERS: dict
|
||||
matching_engine: Any
|
||||
automation_engine: Any
|
||||
get_quality_tier_from_extension: Callable
|
||||
add_activity_item: Callable
|
||||
|
||||
|
||||
def _extract_lookup_value(value: Any, *names: str, default: Any = None) -> Any:
|
||||
|
|
@ -277,3 +300,363 @@ def _search_tracks_for_source(source: str, query: str, limit: int = 5, client: A
|
|||
except Exception as exc:
|
||||
logger.debug("Could not search %s for %s: %s", source, query, exc)
|
||||
return []
|
||||
|
||||
|
||||
def run_quality_scanner(scope='watchlist', profile_id=1, deps: QualityScannerDeps = None):
|
||||
"""Main quality scanner worker function"""
|
||||
from core.wishlist_service import get_wishlist_service
|
||||
from database.music_database import MusicDatabase
|
||||
|
||||
try:
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["status"] = "running"
|
||||
deps.quality_scanner_state["phase"] = "Initializing scan..."
|
||||
deps.quality_scanner_state["progress"] = 0
|
||||
deps.quality_scanner_state["processed"] = 0
|
||||
deps.quality_scanner_state["total"] = 0
|
||||
deps.quality_scanner_state["quality_met"] = 0
|
||||
deps.quality_scanner_state["low_quality"] = 0
|
||||
deps.quality_scanner_state["matched"] = 0
|
||||
deps.quality_scanner_state["results"] = []
|
||||
deps.quality_scanner_state["error_message"] = ""
|
||||
|
||||
logger.info(f"[Quality Scanner] Starting scan with scope: {scope}")
|
||||
|
||||
# Get database instance
|
||||
db = MusicDatabase()
|
||||
|
||||
# Get quality profile to determine preferred quality
|
||||
quality_profile = db.get_quality_profile()
|
||||
preferred_qualities = quality_profile.get('qualities', {})
|
||||
|
||||
# Determine minimum acceptable tier based on enabled qualities
|
||||
min_acceptable_tier = 999
|
||||
for quality_name, quality_config in preferred_qualities.items():
|
||||
if quality_config.get('enabled', False):
|
||||
# Map quality profile names to tier names
|
||||
tier_map = {
|
||||
'flac': 'lossless',
|
||||
'mp3_320': 'low_lossy',
|
||||
'mp3_256': 'low_lossy',
|
||||
'mp3_192': 'low_lossy'
|
||||
}
|
||||
tier_name = tier_map.get(quality_name)
|
||||
if tier_name:
|
||||
tier_num = deps.QUALITY_TIERS[tier_name]['tier']
|
||||
min_acceptable_tier = min(min_acceptable_tier, tier_num)
|
||||
|
||||
logger.info(f"[Quality Scanner] Minimum acceptable tier: {min_acceptable_tier}")
|
||||
|
||||
# Get tracks to scan based on scope
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["phase"] = "Loading tracks from database..."
|
||||
|
||||
if scope == 'watchlist':
|
||||
# Get watchlist artists
|
||||
watchlist_artists = db.get_watchlist_artists(profile_id=profile_id)
|
||||
if not watchlist_artists:
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["status"] = "finished"
|
||||
deps.quality_scanner_state["phase"] = "No watchlist artists found"
|
||||
deps.quality_scanner_state["error_message"] = "Please add artists to watchlist first"
|
||||
logger.warning("[Quality Scanner] No watchlist artists found")
|
||||
return
|
||||
|
||||
# Get artist names from watchlist
|
||||
artist_names = [artist.artist_name for artist in watchlist_artists]
|
||||
logger.info(f"[Quality Scanner] Scanning {len(artist_names)} watchlist artists")
|
||||
|
||||
# Get all tracks for these artists by name
|
||||
conn = db._get_connection()
|
||||
placeholders = ','.join(['?' for _ in artist_names])
|
||||
tracks_to_scan = conn.execute(
|
||||
f"SELECT t.id, t.title, t.artist_id, t.album_id, t.file_path, t.bitrate, a.name as artist_name, al.title as album_title "
|
||||
f"FROM tracks t "
|
||||
f"JOIN artists a ON t.artist_id = a.id "
|
||||
f"JOIN albums al ON t.album_id = al.id "
|
||||
f"WHERE a.name IN ({placeholders}) AND t.file_path IS NOT NULL",
|
||||
artist_names
|
||||
).fetchall()
|
||||
conn.close()
|
||||
else:
|
||||
# Scan all library tracks
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["phase"] = "Loading all library tracks..."
|
||||
|
||||
conn = db._get_connection()
|
||||
tracks_to_scan = conn.execute(
|
||||
"SELECT t.id, t.title, t.artist_id, t.album_id, t.file_path, t.bitrate, a.name as artist_name, al.title as album_title "
|
||||
"FROM tracks t "
|
||||
"JOIN artists a ON t.artist_id = a.id "
|
||||
"JOIN albums al ON t.album_id = al.id "
|
||||
"WHERE t.file_path IS NOT NULL"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
total_tracks = len(tracks_to_scan)
|
||||
logger.info(f"[Quality Scanner] Found {total_tracks} tracks to scan")
|
||||
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["total"] = total_tracks
|
||||
deps.quality_scanner_state["phase"] = f"Scanning {total_tracks} tracks..."
|
||||
|
||||
source_priority = get_source_priority(get_primary_source())
|
||||
if not source_priority:
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["status"] = "error"
|
||||
deps.quality_scanner_state["phase"] = "No metadata provider available"
|
||||
deps.quality_scanner_state["error_message"] = "No metadata provider is available for quality scanning"
|
||||
logger.info("[Quality Scanner] No metadata provider available")
|
||||
return
|
||||
|
||||
logger.info("[Quality Scanner] Using metadata source priority: %s", source_priority)
|
||||
|
||||
wishlist_service = get_wishlist_service()
|
||||
add_to_wishlist = getattr(wishlist_service, 'add_track_to_wishlist', None)
|
||||
if add_to_wishlist is None:
|
||||
add_to_wishlist = getattr(wishlist_service, 'add_spotify_track_to_wishlist', None)
|
||||
if add_to_wishlist is None:
|
||||
raise AttributeError("Wishlist service does not expose an add-to-wishlist method")
|
||||
|
||||
# Scan each track
|
||||
for idx, track_row in enumerate(tracks_to_scan, 1):
|
||||
# Check for stop request
|
||||
if deps.quality_scanner_state.get('status') != 'running':
|
||||
logger.info(f"[Quality Scanner] Stop requested, halting at track {idx}/{total_tracks}")
|
||||
break
|
||||
|
||||
try:
|
||||
track_id, title, artist_id, album_id, file_path, bitrate, artist_name, album_title = track_row
|
||||
|
||||
# Check quality tier
|
||||
tier_name, tier_num = deps.get_quality_tier_from_extension(file_path)
|
||||
|
||||
# Update progress
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["processed"] = idx
|
||||
deps.quality_scanner_state["progress"] = (idx / total_tracks) * 100
|
||||
deps.quality_scanner_state["phase"] = f"Scanning: {artist_name} - {title}"
|
||||
|
||||
# Check if meets quality standards
|
||||
if tier_num <= min_acceptable_tier:
|
||||
# Quality met
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["quality_met"] += 1
|
||||
continue
|
||||
|
||||
# Low quality track found
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["low_quality"] += 1
|
||||
|
||||
logger.info(f"[Quality Scanner] Low quality: {artist_name} - {title} ({tier_name}, {file_path})")
|
||||
|
||||
# Attempt to match using the active metadata provider
|
||||
matched = False
|
||||
matched_track_data = None
|
||||
best_source = None
|
||||
attempted_any_provider = False
|
||||
|
||||
try:
|
||||
# Generate search queries using matching engine
|
||||
temp_track = type('TempTrack', (), {
|
||||
'name': title,
|
||||
'artists': [artist_name],
|
||||
'album': album_title
|
||||
})()
|
||||
|
||||
search_queries = deps.matching_engine.generate_download_queries(temp_track)
|
||||
logger.info(f"[Quality Scanner] Generated {len(search_queries)} search queries for {artist_name} - {title}")
|
||||
|
||||
# Find best match using confidence scoring
|
||||
best_match = None
|
||||
best_confidence = 0.0
|
||||
min_confidence = 0.7 # Match existing standard
|
||||
|
||||
for _query_idx, search_query in enumerate(search_queries):
|
||||
try:
|
||||
for source in source_priority:
|
||||
client = get_client_for_source(source)
|
||||
if not client or not hasattr(client, 'search_tracks'):
|
||||
continue
|
||||
|
||||
attempted_any_provider = True
|
||||
provider_matches = _search_tracks_for_source(source, search_query, limit=5, client=client)
|
||||
time.sleep(0.5) # Rate limit metadata API calls
|
||||
|
||||
if not provider_matches:
|
||||
continue
|
||||
|
||||
# Score each result using matching engine
|
||||
for provider_track in provider_matches:
|
||||
try:
|
||||
# Calculate artist confidence
|
||||
artist_confidence = 0.0
|
||||
provider_artists = _track_artist_names(provider_track)
|
||||
if provider_artists:
|
||||
for result_artist in provider_artists:
|
||||
artist_sim = deps.matching_engine.similarity_score(
|
||||
deps.matching_engine.normalize_string(artist_name),
|
||||
deps.matching_engine.normalize_string(result_artist)
|
||||
)
|
||||
artist_confidence = max(artist_confidence, artist_sim)
|
||||
|
||||
# Calculate title confidence
|
||||
title_confidence = deps.matching_engine.similarity_score(
|
||||
deps.matching_engine.normalize_string(title),
|
||||
deps.matching_engine.normalize_string(_track_name(provider_track))
|
||||
)
|
||||
|
||||
# Combined confidence (50% artist + 50% title)
|
||||
combined_confidence = (artist_confidence * 0.5 + title_confidence * 0.5)
|
||||
|
||||
# Small bonus for album tracks over singles
|
||||
_at = _extract_lookup_value(provider_track, 'album_type', default='') or ''
|
||||
if _at == 'album':
|
||||
combined_confidence += 0.02
|
||||
elif _at == 'ep':
|
||||
combined_confidence += 0.01
|
||||
|
||||
candidate_artist = provider_artists[0] if provider_artists else 'Unknown Artist'
|
||||
candidate_name = _track_name(provider_track)
|
||||
logger.info(
|
||||
f"[Quality Scanner] Candidate ({source}): '{candidate_artist}' - "
|
||||
f"'{candidate_name}' (confidence: {combined_confidence:.3f})"
|
||||
)
|
||||
|
||||
# Update best match if this is better
|
||||
if combined_confidence > best_confidence and combined_confidence >= min_confidence:
|
||||
best_confidence = combined_confidence
|
||||
best_match = provider_track
|
||||
best_source = source
|
||||
logger.info(
|
||||
f"[Quality Scanner] New best match ({source}): {candidate_artist} - "
|
||||
f"{candidate_name} (confidence: {combined_confidence:.3f})"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Quality Scanner] Error scoring result: {e}")
|
||||
continue
|
||||
|
||||
# If we found a very high confidence match, stop searching this query
|
||||
if best_confidence >= 0.9:
|
||||
logger.info(f"[Quality Scanner] High confidence match found ({best_confidence:.3f}), stopping search")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[Quality Scanner] Error searching with query '{search_query}': {e}")
|
||||
continue
|
||||
|
||||
if not attempted_any_provider:
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["status"] = "error"
|
||||
deps.quality_scanner_state["phase"] = "No metadata provider available"
|
||||
deps.quality_scanner_state["error_message"] = "No metadata provider is available for quality scanning"
|
||||
logger.info("[Quality Scanner] No metadata provider available")
|
||||
return
|
||||
|
||||
# Process best match
|
||||
if best_match:
|
||||
matched = True
|
||||
final_artist = _track_artist_names(best_match)[0] if _track_artist_names(best_match) else 'Unknown Artist'
|
||||
final_name = _track_name(best_match)
|
||||
final_source = best_source or 'metadata'
|
||||
logger.info(
|
||||
f"[Quality Scanner] Final match ({final_source}): {final_artist} - "
|
||||
f"{final_name} (confidence: {best_confidence:.3f})"
|
||||
)
|
||||
|
||||
# Build normalized track data for wishlist
|
||||
matched_track_data = _normalize_track_match(best_match, final_source)
|
||||
|
||||
# Add to wishlist
|
||||
source_context = {
|
||||
'quality_scanner': True,
|
||||
'original_file_path': file_path,
|
||||
'original_format': tier_name,
|
||||
'original_bitrate': bitrate,
|
||||
'match_confidence': best_confidence,
|
||||
'scan_date': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
success = add_to_wishlist(
|
||||
track_data=matched_track_data,
|
||||
failure_reason=f"Low quality - {tier_name.replace('_', ' ').title()} format",
|
||||
source_type='quality_scanner',
|
||||
source_context=source_context,
|
||||
profile_id=profile_id
|
||||
)
|
||||
|
||||
if success:
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["matched"] += 1
|
||||
logger.info(f"[Quality Scanner] Matched and added to wishlist: {artist_name} - {title}")
|
||||
else:
|
||||
logger.error(f"[Quality Scanner] Failed to add to wishlist: {artist_name} - {title}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"[Quality Scanner] No suitable metadata match found "
|
||||
f"(best confidence: {best_confidence:.3f}, required: {min_confidence:.3f})"
|
||||
)
|
||||
|
||||
except Exception as matching_error:
|
||||
logger.error(f"[Quality Scanner] Matching error for {artist_name} - {title}: {matching_error}")
|
||||
|
||||
# Store result
|
||||
result_entry = {
|
||||
'track_id': track_id,
|
||||
'title': title,
|
||||
'artist': artist_name,
|
||||
'album': album_title,
|
||||
'file_path': file_path,
|
||||
'current_format': tier_name,
|
||||
'bitrate': bitrate,
|
||||
'matched': matched,
|
||||
'match_id': matched_track_data['id'] if matched_track_data else None,
|
||||
'provider': best_source if matched else None,
|
||||
'spotify_id': matched_track_data['id'] if matched_track_data else None,
|
||||
}
|
||||
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["results"].append(result_entry)
|
||||
|
||||
if not matched:
|
||||
logger.warning(f"[Quality Scanner] No metadata match found for: {artist_name} - {title}")
|
||||
|
||||
except Exception as track_error:
|
||||
logger.error(f"[Quality Scanner] Error processing track: {track_error}")
|
||||
continue
|
||||
|
||||
# Scan complete (don't overwrite if already stopped by user)
|
||||
with deps.quality_scanner_lock:
|
||||
was_stopped = deps.quality_scanner_state["status"] != "running"
|
||||
deps.quality_scanner_state["status"] = "finished"
|
||||
deps.quality_scanner_state["progress"] = 100
|
||||
if not was_stopped:
|
||||
deps.quality_scanner_state["phase"] = "Scan complete"
|
||||
|
||||
logger.info(f"[Quality Scanner] Scan {'stopped' if was_stopped else 'complete'}: {deps.quality_scanner_state['processed']} processed, "
|
||||
f"{deps.quality_scanner_state['low_quality']} low quality, {deps.quality_scanner_state['matched']} matched to metadata providers")
|
||||
|
||||
# Add activity
|
||||
deps.add_activity_item("", "Quality Scan Complete",
|
||||
f"{deps.quality_scanner_state['matched']} tracks added to wishlist", "Now")
|
||||
|
||||
try:
|
||||
if deps.automation_engine:
|
||||
deps.automation_engine.emit('quality_scan_completed', {
|
||||
'quality_met': str(deps.quality_scanner_state.get('quality_met', 0)),
|
||||
'low_quality': str(deps.quality_scanner_state.get('low_quality', 0)),
|
||||
'total_scanned': str(deps.quality_scanner_state.get('processed', 0)),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug("emit quality_scan_completed failed: %s", e)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Quality Scanner] Critical error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["status"] = "error"
|
||||
deps.quality_scanner_state["error_message"] = str(e)
|
||||
deps.quality_scanner_state["phase"] = f"Error: {str(e)}"
|
||||
|
|
|
|||
|
|
@ -47,229 +47,9 @@ class SyncDeps:
|
|||
update_and_save_sync_status: Callable
|
||||
sync_states: dict
|
||||
sync_lock: Any # threading.Lock
|
||||
# Optional: post-sync download follow-up for mirrored-playlist automations.
|
||||
process_wishlist_automatically: Callable[..., Any] | None = None
|
||||
run_playlist_organize_download: Callable[..., Any] | None = None
|
||||
is_wishlist_actually_processing: Callable[[], bool] | None = None
|
||||
|
||||
|
||||
def _post_sync_automation_followup(
|
||||
deps: SyncDeps,
|
||||
*,
|
||||
automation_id: str,
|
||||
playlist_id: str,
|
||||
skip_wishlist_add: bool,
|
||||
result: Any,
|
||||
) -> None:
|
||||
"""Queue downloads after an automation sync finishes.
|
||||
|
||||
Sync Playlist runs in a background thread and returns immediately, so a
|
||||
separate scheduled "Process Wishlist" action often runs on an empty wishlist.
|
||||
Organize-by-playlist skips sync-time wishlist adds and expects a folder
|
||||
download batch instead — that only ran in Playlist Pipeline before this hook.
|
||||
"""
|
||||
if not automation_id or not str(playlist_id).startswith('auto_mirror_'):
|
||||
return
|
||||
try:
|
||||
mirrored_id = int(str(playlist_id).replace('auto_mirror_', '', 1))
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
failed = int(getattr(result, 'failed_tracks', 0) or 0)
|
||||
wishlist_added = int(getattr(result, 'wishlist_added_count', 0) or 0)
|
||||
|
||||
if skip_wishlist_add:
|
||||
org_fn = deps.run_playlist_organize_download
|
||||
if failed <= 0:
|
||||
return
|
||||
if not org_fn:
|
||||
logger.warning(
|
||||
"Organize-by-playlist sync left %s missing tracks but organize download is unavailable",
|
||||
failed,
|
||||
)
|
||||
deps.update_automation_progress(
|
||||
automation_id,
|
||||
log_line=f'{failed} missing — enable Playlist Pipeline or disable Organize by Playlist',
|
||||
log_type='warning',
|
||||
)
|
||||
return
|
||||
org_result = org_fn(mirrored_playlist_id=mirrored_id, automation_id=automation_id)
|
||||
status = org_result.get('status', 'unknown') if isinstance(org_result, dict) else 'unknown'
|
||||
reason = org_result.get('reason', '') if isinstance(org_result, dict) else ''
|
||||
log_type = 'success' if status == 'started' else 'warning'
|
||||
detail = f' ({reason})' if reason and status != 'started' else ''
|
||||
deps.update_automation_progress(
|
||||
automation_id,
|
||||
log_line=f'Organize download {status} for {failed} missing track(s){detail}',
|
||||
log_type=log_type,
|
||||
)
|
||||
return
|
||||
|
||||
if wishlist_added <= 0:
|
||||
if failed > 0:
|
||||
deps.update_automation_progress(
|
||||
automation_id,
|
||||
log_line=f'{failed} missing but none added to wishlist — check logs',
|
||||
log_type='warning',
|
||||
)
|
||||
return
|
||||
|
||||
proc_fn = deps.process_wishlist_automatically
|
||||
if not proc_fn:
|
||||
return
|
||||
is_busy = deps.is_wishlist_actually_processing
|
||||
if is_busy and is_busy():
|
||||
deps.update_automation_progress(
|
||||
automation_id,
|
||||
log_line=f'Added {wishlist_added} to wishlist; download worker already running',
|
||||
log_type='info',
|
||||
)
|
||||
return
|
||||
proc_fn(automation_id=automation_id)
|
||||
deps.update_automation_progress(
|
||||
automation_id,
|
||||
log_line=f'Started wishlist download for {wishlist_added} track(s)',
|
||||
log_type='success',
|
||||
)
|
||||
|
||||
|
||||
async def _database_only_find_track(spotify_track, candidate_pool=None):
|
||||
"""Database-only track matcher used when no media server is connected.
|
||||
|
||||
Patched onto sync_service._find_track_in_media_server. Accepts
|
||||
``candidate_pool`` for interface parity with the real matcher (sync_service
|
||||
calls it with candidate_pool=...); the DB path queries the library directly
|
||||
via check_track_exists, so it doesn't need the per-artist candidate cache —
|
||||
but it MUST accept the kwarg or sync raises "unexpected keyword argument
|
||||
'candidate_pool'". Module-level (not a nested closure) so it's importable
|
||||
and unit-tested.
|
||||
"""
|
||||
logger.info(f"Database-only search for: '{spotify_track.name}' by {spotify_track.artists}")
|
||||
try:
|
||||
from database.music_database import MusicDatabase
|
||||
from config.settings import config_manager
|
||||
|
||||
db = MusicDatabase()
|
||||
active_server = config_manager.get_active_media_server()
|
||||
original_title = spotify_track.name
|
||||
spotify_id = getattr(spotify_track, 'id', '') or ''
|
||||
|
||||
# --- Sync match cache fast-path ---
|
||||
if spotify_id:
|
||||
try:
|
||||
cached = db.read_sync_match_cache(spotify_id, active_server)
|
||||
if cached:
|
||||
db_track_check = db.get_track_by_id(cached['server_track_id'])
|
||||
if db_track_check:
|
||||
class DatabaseTrackCached:
|
||||
def __init__(self, db_t):
|
||||
self.ratingKey = db_t.id
|
||||
self.title = db_t.title
|
||||
self.id = db_t.id
|
||||
logger.debug(f"Sync cache hit: '{original_title}' → server track {cached['server_track_id']}")
|
||||
return DatabaseTrackCached(db_track_check), cached['confidence']
|
||||
logger.warning(f"Sync cache stale for '{original_title}' — track gone")
|
||||
except Exception as e:
|
||||
logger.debug("sync match cache fast-path failed: %s", e)
|
||||
# --- End cache fast-path ---
|
||||
|
||||
# Durable manual library match (#787) — survives a library rescan (the
|
||||
# sync_match_cache above does not), so a user's Find & Add pairing keeps
|
||||
# sticking across auto-syncs instead of being re-matched from scratch (#895
|
||||
# follow-up). Self-heals a stale library id via the stored file path.
|
||||
if spotify_id:
|
||||
try:
|
||||
from core.artists.map import get_current_profile_id
|
||||
m = db.find_manual_library_match_by_source_track_id(
|
||||
get_current_profile_id(), str(spotify_id), active_server)
|
||||
if m:
|
||||
lib_id = m.get('library_track_id')
|
||||
dt = db.get_track_by_id(lib_id) if lib_id is not None else None
|
||||
if not dt and m.get('library_file_path'):
|
||||
new_id = db.find_track_id_by_file_path(m['library_file_path'])
|
||||
dt = db.get_track_by_id(new_id) if new_id else None
|
||||
if dt:
|
||||
class DatabaseTrackDurable:
|
||||
def __init__(self, db_t):
|
||||
self.ratingKey = db_t.id
|
||||
self.title = db_t.title
|
||||
self.id = db_t.id
|
||||
logger.debug(f"Durable manual match hit: '{original_title}' → {lib_id}")
|
||||
return DatabaseTrackDurable(dt), 1.0
|
||||
except Exception as e:
|
||||
logger.debug("durable manual match fast-path failed: %s", e)
|
||||
|
||||
# Try each artist
|
||||
for artist in spotify_track.artists:
|
||||
if isinstance(artist, str):
|
||||
artist_name = artist
|
||||
elif isinstance(artist, dict) and 'name' in artist:
|
||||
artist_name = artist['name']
|
||||
else:
|
||||
artist_name = str(artist)
|
||||
|
||||
db_track, confidence = db.check_track_exists(
|
||||
original_title, artist_name,
|
||||
confidence_threshold=0.80,
|
||||
server_source=active_server
|
||||
)
|
||||
|
||||
if not (db_track and confidence >= 0.80):
|
||||
# #785: file/CSV playlists keep raw "Artist - Title" titles (unlike
|
||||
# YouTube, cleaned at ingest), which don't match the clean library
|
||||
# title. Retry with the canonical form (best-of, conservative).
|
||||
try:
|
||||
from core.text.source_title import canonical_source_track
|
||||
_canon_title, _canon_artist = canonical_source_track(original_title, artist_name)
|
||||
if (_canon_title, _canon_artist) != (original_title, artist_name):
|
||||
_alt_track, _alt_conf = db.check_track_exists(
|
||||
_canon_title, _canon_artist,
|
||||
confidence_threshold=0.80, server_source=active_server)
|
||||
if _alt_track and _alt_conf > confidence:
|
||||
db_track, confidence = _alt_track, _alt_conf
|
||||
except Exception as _canon_err:
|
||||
logger.debug("canonical retry failed: %s", _canon_err)
|
||||
|
||||
if db_track and confidence >= 0.80:
|
||||
logger.info(f"Database match: '{db_track.title}' (confidence: {confidence:.2f})")
|
||||
if spotify_id:
|
||||
try:
|
||||
from core.matching_engine import MusicMatchingEngine
|
||||
me = MusicMatchingEngine()
|
||||
db.save_sync_match_cache(
|
||||
spotify_id, me.clean_title(original_title), me.clean_artist(artist_name),
|
||||
active_server, db_track.id, db_track.title, confidence
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("save sync match cache failed: %s", e)
|
||||
|
||||
class DatabaseTrackMock:
|
||||
def __init__(self, db_track):
|
||||
self.ratingKey = db_track.id
|
||||
self.title = db_track.title
|
||||
self.id = db_track.id
|
||||
|
||||
return DatabaseTrackMock(db_track), confidence
|
||||
|
||||
logger.warning(f"No database match found for: '{original_title}'")
|
||||
return None, 0.0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database search error: {e}")
|
||||
return None, 0.0
|
||||
|
||||
|
||||
def run_sync_task(
|
||||
playlist_id,
|
||||
playlist_name,
|
||||
tracks_json,
|
||||
automation_id=None,
|
||||
profile_id=1,
|
||||
playlist_image_url='',
|
||||
deps: SyncDeps = None,
|
||||
sync_mode: str = 'replace',
|
||||
skip_wishlist_add: bool = False,
|
||||
):
|
||||
def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, profile_id=1, playlist_image_url='', deps: SyncDeps = None, sync_mode: str = 'replace'):
|
||||
"""The actual sync function that runs in the background thread."""
|
||||
sync_states = deps.sync_states
|
||||
sync_lock = deps.sync_lock
|
||||
|
|
@ -307,11 +87,35 @@ def run_sync_task(
|
|||
# This avoids needing to re-fetch it from Spotify
|
||||
logger.info("Converting JSON tracks to SpotifyTrack objects...")
|
||||
|
||||
# Store original track data with full album objects (for wishlist with cover art).
|
||||
# Shared with the sync-detail "re-add to wishlist" action so both build the
|
||||
# IDENTICAL payload (album→dict + images, artists→dicts). Copy-safe.
|
||||
from core.sync.wishlist_readd import build_original_tracks_map
|
||||
original_tracks_map = build_original_tracks_map(tracks_json)
|
||||
# Store original track data with full album objects (for wishlist with cover art)
|
||||
# Normalize formats for wishlist: album must be dict {'name': ...}, artists must be [{'name': ...}]
|
||||
# Important: copy data — don't mutate tracks_json since SpotifyTrack expects List[str] artists
|
||||
original_tracks_map = {}
|
||||
for t in tracks_json:
|
||||
track_id = t.get('id', '')
|
||||
if track_id:
|
||||
normalized = dict(t)
|
||||
# Normalize album to dict format, preserving images and metadata
|
||||
raw_album = normalized.get('album', '')
|
||||
if isinstance(raw_album, str):
|
||||
normalized['album'] = {
|
||||
'name': raw_album or normalized.get('name', 'Unknown Album'),
|
||||
'images': [], 'album_type': 'single', 'total_tracks': 1, 'release_date': ''
|
||||
}
|
||||
elif not isinstance(raw_album, dict):
|
||||
normalized['album'] = {
|
||||
'name': str(raw_album) if raw_album else normalized.get('name', 'Unknown Album'),
|
||||
'images': [], 'album_type': 'single', 'total_tracks': 1, 'release_date': ''
|
||||
}
|
||||
else:
|
||||
# Dict — ensure required keys exist
|
||||
raw_album.setdefault('name', 'Unknown Album')
|
||||
raw_album.setdefault('images', [])
|
||||
# Normalize artists to list of dicts
|
||||
raw_artists = normalized.get('artists', [])
|
||||
if raw_artists and isinstance(raw_artists[0], str):
|
||||
normalized['artists'] = [{'name': a} for a in raw_artists]
|
||||
original_tracks_map[track_id] = normalized
|
||||
|
||||
tracks = []
|
||||
for i, t in enumerate(tracks_json):
|
||||
|
|
@ -456,9 +260,89 @@ def run_sync_task(
|
|||
if media_client is None or not media_client.is_connected():
|
||||
logger.info("Media client not connected - patching sync service for database-only matching")
|
||||
|
||||
# Patch the matcher to the module-level database-only implementation
|
||||
# (importable + unit-tested; accepts candidate_pool for parity).
|
||||
sync_service._find_track_in_media_server = _database_only_find_track
|
||||
# Store original method
|
||||
original_find_track = sync_service._find_track_in_media_server
|
||||
|
||||
# Create database-only replacement method
|
||||
async def database_only_find_track(spotify_track):
|
||||
logger.info(f"Database-only search for: '{spotify_track.name}' by {spotify_track.artists}")
|
||||
try:
|
||||
from database.music_database import MusicDatabase
|
||||
from config.settings import config_manager
|
||||
|
||||
db = MusicDatabase()
|
||||
active_server = config_manager.get_active_media_server()
|
||||
original_title = spotify_track.name
|
||||
spotify_id = getattr(spotify_track, 'id', '') or ''
|
||||
|
||||
# --- Sync match cache fast-path ---
|
||||
if spotify_id:
|
||||
try:
|
||||
cached = db.read_sync_match_cache(spotify_id, active_server)
|
||||
if cached:
|
||||
db_track_check = db.get_track_by_id(cached['server_track_id'])
|
||||
if db_track_check:
|
||||
class DatabaseTrackCached:
|
||||
def __init__(self, db_t):
|
||||
self.ratingKey = db_t.id
|
||||
self.title = db_t.title
|
||||
self.id = db_t.id
|
||||
logger.debug(f"Sync cache hit: '{original_title}' → server track {cached['server_track_id']}")
|
||||
return DatabaseTrackCached(db_track_check), cached['confidence']
|
||||
logger.warning(f"Sync cache stale for '{original_title}' — track gone")
|
||||
except Exception as e:
|
||||
logger.debug("sync match cache fast-path failed: %s", e)
|
||||
# --- End cache fast-path ---
|
||||
|
||||
# Try each artist (same logic as original)
|
||||
for artist in spotify_track.artists:
|
||||
# Extract artist name from both string and dict formats
|
||||
if isinstance(artist, str):
|
||||
artist_name = artist
|
||||
elif isinstance(artist, dict) and 'name' in artist:
|
||||
artist_name = artist['name']
|
||||
else:
|
||||
artist_name = str(artist)
|
||||
|
||||
db_track, confidence = db.check_track_exists(
|
||||
original_title, artist_name,
|
||||
confidence_threshold=0.80,
|
||||
server_source=active_server
|
||||
)
|
||||
|
||||
if db_track and confidence >= 0.80:
|
||||
logger.info(f"Database match: '{db_track.title}' (confidence: {confidence:.2f})")
|
||||
|
||||
# Save to sync match cache
|
||||
if spotify_id:
|
||||
try:
|
||||
from core.matching_engine import MusicMatchingEngine
|
||||
me = MusicMatchingEngine()
|
||||
db.save_sync_match_cache(
|
||||
spotify_id, me.clean_title(original_title), me.clean_artist(artist_name),
|
||||
active_server, db_track.id, db_track.title, confidence
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("save sync match cache failed: %s", e)
|
||||
|
||||
# Create mock track object for playlist creation
|
||||
class DatabaseTrackMock:
|
||||
def __init__(self, db_track):
|
||||
self.ratingKey = db_track.id
|
||||
self.title = db_track.title
|
||||
self.id = db_track.id
|
||||
|
||||
return DatabaseTrackMock(db_track), confidence
|
||||
|
||||
logger.warning(f"No database match found for: '{original_title}'")
|
||||
return None, 0.0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database search error: {e}")
|
||||
return None, 0.0
|
||||
|
||||
# Patch the method
|
||||
sync_service._find_track_in_media_server = database_only_find_track
|
||||
logger.info("Patched sync service to use database-only matching")
|
||||
|
||||
sync_start_time = time.time()
|
||||
|
|
@ -472,14 +356,7 @@ def run_sync_task(
|
|||
# Wing It mode — skip wishlist for unmatched tracks
|
||||
with sync_lock:
|
||||
is_wing_it = sync_states.get(playlist_id, {}).get('wing_it', False)
|
||||
sync_service._skip_unmatched_wishlist = is_wing_it or skip_wishlist_add
|
||||
sync_service._skip_wishlist = is_wing_it
|
||||
if skip_wishlist_add:
|
||||
logger.info(
|
||||
"[Organize by Playlist] Skipping sync-time wishlist for '%s' — "
|
||||
"organize download + batch failure handling cover missing tracks",
|
||||
playlist_name,
|
||||
)
|
||||
|
||||
# Run the sync (this is a blocking call within this thread)
|
||||
result = deps.run_async(sync_service.sync_playlist(playlist, download_missing=False, profile_id=profile_id, sync_mode=sync_mode))
|
||||
|
|
@ -526,13 +403,7 @@ def run_sync_task(
|
|||
# don't want persisted to app.log.
|
||||
_synced = getattr(result, 'synced_tracks', 0)
|
||||
logger.info(f"[PLAYLIST IMAGE] has_image={bool(playlist_image_url)}, synced_tracks={_synced}")
|
||||
# Modes that edit a playlist in place (reconcile #792, append #811) must
|
||||
# NOT push the source image — doing so re-clobbers a user's custom poster
|
||||
# every sync, the exact bug these modes exist to avoid. Only the
|
||||
# destructive 'replace' (recreate-from-scratch) pushes the image.
|
||||
if sync_mode in ('reconcile', 'append'):
|
||||
logger.info(f"[PLAYLIST IMAGE] {sync_mode} mode — preserving existing playlist image")
|
||||
elif playlist_image_url and _synced > 0:
|
||||
if playlist_image_url and _synced > 0:
|
||||
try:
|
||||
active_server = deps.config_manager.get_active_media_server()
|
||||
logger.info(f"[PLAYLIST IMAGE] active_server={active_server}")
|
||||
|
|
@ -584,21 +455,9 @@ def run_sync_task(
|
|||
matched = getattr(result, 'matched_tracks', 0)
|
||||
total = getattr(result, 'total_tracks', 0)
|
||||
failed = getattr(result, 'failed_tracks', 0)
|
||||
wishlist_added = getattr(result, 'wishlist_added_count', 0) or 0
|
||||
deps.update_automation_progress(automation_id, status='finished', progress=100,
|
||||
phase='Sync complete',
|
||||
log_line=(
|
||||
f'Done: {matched}/{total} in library, {failed} missing'
|
||||
+ (f', {wishlist_added} added to wishlist' if wishlist_added else '')
|
||||
),
|
||||
log_type='success')
|
||||
_post_sync_automation_followup(
|
||||
deps,
|
||||
automation_id=automation_id,
|
||||
playlist_id=playlist_id,
|
||||
skip_wishlist_add=skip_wishlist_add,
|
||||
result=result,
|
||||
)
|
||||
log_line=f'Done: {matched}/{total} matched, {failed} failed', log_type='success')
|
||||
|
||||
# Emit playlist_synced event for automation engine
|
||||
try:
|
||||
|
|
@ -617,28 +476,12 @@ def run_sync_task(
|
|||
import hashlib as _hl
|
||||
_track_ids_str = ','.join(sorted(t.get('id', '') for t in tracks_json))
|
||||
_tracks_hash = _hl.md5(_track_ids_str.encode()).hexdigest()
|
||||
_mirror_tracks_hash = None
|
||||
if str(playlist_id).startswith('auto_mirror_'):
|
||||
try:
|
||||
_mp_id = int(str(playlist_id).replace('auto_mirror_', '', 1))
|
||||
from database.music_database import MusicDatabase
|
||||
_mtracks = MusicDatabase().get_mirrored_playlist_tracks(_mp_id)
|
||||
_mids = ','.join(
|
||||
sorted(t.get('source_track_id', '') or '' for t in _mtracks if t.get('source_track_id'))
|
||||
)
|
||||
_mirror_tracks_hash = _hl.md5(_mids.encode()).hexdigest() if _mids else ''
|
||||
except Exception as e:
|
||||
logger.debug("mirror_tracks_hash for sync status: %s", e)
|
||||
snapshot_id = getattr(playlist, 'snapshot_id', None)
|
||||
_status_kwargs = dict(
|
||||
deps.update_and_save_sync_status(playlist_id, playlist_name, playlist.owner, snapshot_id,
|
||||
matched_tracks=getattr(result, 'matched_tracks', 0),
|
||||
total_tracks=getattr(result, 'total_tracks', 0),
|
||||
discovered_tracks=len(tracks_json),
|
||||
tracks_hash=_tracks_hash,
|
||||
)
|
||||
if _mirror_tracks_hash is not None:
|
||||
_status_kwargs['mirror_tracks_hash'] = _mirror_tracks_hash
|
||||
deps.update_and_save_sync_status(playlist_id, playlist_name, playlist.owner, snapshot_id, **_status_kwargs)
|
||||
tracks_hash=_tracks_hash)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SYNC FAILED for {playlist_id}: {e}")
|
||||
|
|
|
|||
|
|
@ -32,26 +32,6 @@ from typing import Any, Callable
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_UNKNOWN_ARTIST = 'Unknown Artist'
|
||||
|
||||
|
||||
def resolve_display_artist(yt_artist: str, matched_artist: str) -> str:
|
||||
"""The artist to show in the 'YT Artist' column (#909).
|
||||
|
||||
YouTube's flat playlist data carries no artist, so a track starts as
|
||||
"Unknown Artist" and only gains a real name if per-video recovery succeeds.
|
||||
When recovery comes up empty but the track still matched confidently, show
|
||||
the matched artist instead of a misleading "Unknown Artist". Returns the
|
||||
original ``yt_artist`` whenever it's already a real name (recovery worked) or
|
||||
when there's no matched artist to fall back to — purely a display choice, the
|
||||
match itself is unaffected.
|
||||
"""
|
||||
current = (yt_artist or '').strip()
|
||||
if current and current != _UNKNOWN_ARTIST:
|
||||
return current # recovery already gave a real name — keep it
|
||||
fallback = (matched_artist or '').strip()
|
||||
return fallback or _UNKNOWN_ARTIST # backfill from the match, else honest Unknown
|
||||
|
||||
|
||||
@dataclass
|
||||
class YoutubeDiscoveryDeps:
|
||||
|
|
@ -72,10 +52,6 @@ class YoutubeDiscoveryDeps:
|
|||
build_discovery_wing_it_stub: Callable
|
||||
get_database: Callable[[], Any]
|
||||
add_activity_item: Callable
|
||||
# Recover a YouTube track's artist from its own video page when flat playlist
|
||||
# extraction left it "Unknown Artist" (#863). Takes a video id, returns a raw
|
||||
# artist string or ''. Optional — discovery still works without it.
|
||||
recover_youtube_artist: Callable[[str], str] = None
|
||||
|
||||
|
||||
def run_youtube_discovery_worker(url_hash, deps: YoutubeDiscoveryDeps):
|
||||
|
|
@ -118,30 +94,6 @@ def run_youtube_discovery_worker(url_hash, deps: YoutubeDiscoveryDeps):
|
|||
cleaned_title = track['name']
|
||||
cleaned_artist = track['artists'][0] if track['artists'] else 'Unknown Artist'
|
||||
|
||||
# Recover the artist from the track's own video page if flat
|
||||
# playlist extraction left it Unknown (#863). Done here, in the
|
||||
# background worker, rather than in the parse request (which would
|
||||
# block for minutes on a big playlist). Per-track cost is hidden
|
||||
# behind the discovery progress bar; the recovered artist makes the
|
||||
# match below actually find the song.
|
||||
if cleaned_artist == 'Unknown Artist' and track.get('id'):
|
||||
if not deps.recover_youtube_artist:
|
||||
logger.warning("[YT Discovery] artist recovery unavailable (dep not wired) "
|
||||
"— '%s' stays Unknown", cleaned_title)
|
||||
else:
|
||||
try:
|
||||
_rec = deps.recover_youtube_artist(track['id'])
|
||||
except Exception as _rec_err:
|
||||
logger.warning(f"[YT Discovery] artist recovery raised for {track.get('id')}: {_rec_err}")
|
||||
_rec = ''
|
||||
if _rec and _rec != 'Unknown Artist':
|
||||
logger.info(f"[YT Discovery] recovered artist '{_rec}' for '{cleaned_title}' ({track['id']})")
|
||||
cleaned_artist = _rec
|
||||
track['artists'] = [_rec] # persist so retries/UI see it
|
||||
else:
|
||||
logger.info(f"[YT Discovery] artist recovery returned nothing for "
|
||||
f"'{cleaned_title}' ({track['id']}) — leaving Unknown")
|
||||
|
||||
logger.info(f"Searching {discovery_source} for: '{cleaned_artist}' - '{cleaned_title}'")
|
||||
|
||||
# Check discovery cache first
|
||||
|
|
@ -151,15 +103,14 @@ def run_youtube_discovery_worker(url_hash, deps: YoutubeDiscoveryDeps):
|
|||
cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source)
|
||||
if cached_match and deps.validate_discovery_cache_artist(cleaned_artist, cached_match):
|
||||
logger.debug(f"CACHE HIT [{i+1}/{len(tracks)}]: {cleaned_artist} - {cleaned_title}")
|
||||
_match_artist = deps.extract_artist_name(cached_match.get('artists', [''])[0]) if cached_match.get('artists') else ''
|
||||
result = {
|
||||
'index': i,
|
||||
'yt_track': cleaned_title,
|
||||
'yt_artist': resolve_display_artist(cleaned_artist, _match_artist),
|
||||
'yt_artist': cleaned_artist,
|
||||
'status': 'Found',
|
||||
'status_class': 'found',
|
||||
'spotify_track': cached_match.get('name', ''),
|
||||
'spotify_artist': _match_artist,
|
||||
'spotify_artist': deps.extract_artist_name(cached_match.get('artists', [''])[0]) if cached_match.get('artists') else '',
|
||||
'spotify_album': cached_match.get('album', {}).get('name', '') if isinstance(cached_match.get('album'), dict) else cached_match.get('album', ''),
|
||||
'duration': f"{int(track['duration_ms']) // 60000}:{(int(track['duration_ms']) % 60000) // 1000:02d}" if track['duration_ms'] else '0:00',
|
||||
'discovery_source': discovery_source,
|
||||
|
|
@ -286,17 +237,15 @@ def run_youtube_discovery_worker(url_hash, deps: YoutubeDiscoveryDeps):
|
|||
best_confidence = confidence
|
||||
logger.info(f"Strategy 4 YouTube match (extended): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
||||
|
||||
# Create result entry. yt_artist falls back to the matched artist when
|
||||
# YouTube/recovery left it "Unknown Artist" but we matched confidently (#909).
|
||||
_match_artist = deps.extract_artist_name(matched_track.artists[0]) if matched_track else ''
|
||||
# Create result entry
|
||||
result = {
|
||||
'index': i,
|
||||
'yt_track': cleaned_title,
|
||||
'yt_artist': resolve_display_artist(cleaned_artist, _match_artist),
|
||||
'yt_artist': cleaned_artist,
|
||||
'status': 'Found' if matched_track else 'Not Found',
|
||||
'status_class': 'found' if matched_track else 'not-found',
|
||||
'spotify_track': matched_track.name if matched_track else '',
|
||||
'spotify_artist': _match_artist,
|
||||
'spotify_artist': deps.extract_artist_name(matched_track.artists[0]) if matched_track else '',
|
||||
'spotify_album': matched_track.album if matched_track else '',
|
||||
'duration': f"{int(track['duration_ms']) // 60000}:{(int(track['duration_ms']) % 60000) // 1000:02d}" if track['duration_ms'] else '0:00',
|
||||
'discovery_source': discovery_source,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ big-bang switchover.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
||||
|
||||
|
|
@ -392,16 +391,6 @@ class DownloadEngine:
|
|||
(tracks, albums) tuple, or ``([], [])`` when every source
|
||||
in the chain is exhausted.
|
||||
|
||||
Priority mode is deliberately quality-AGNOSTIC at search time — source
|
||||
order is king and the first source that returns any tracks wins, exactly
|
||||
matching pre-quality-system behaviour byte-for-byte (#896 review #3).
|
||||
Quality-gating the priority path would deprioritise e.g. a soulseek
|
||||
mp3 whose bitrate slskd omitted (``bitrate=None`` → "unsatisfied"),
|
||||
changing which source wins and adding latency for users who never opted
|
||||
in. Cross-source quality pooling is the job of best_quality mode
|
||||
(``search_all_sources``); final per-result ranking still happens in the
|
||||
orchestrator's match/quality filter. RAW tracks are returned.
|
||||
|
||||
Replaces orchestrator's hand-rolled hybrid search loop. The
|
||||
chain is ordered (most-preferred first).
|
||||
"""
|
||||
|
|
@ -417,10 +406,9 @@ class DownloadEngine:
|
|||
try:
|
||||
logger.info(f"Trying {source_name} (priority {i+1}): {query}")
|
||||
tracks, albums = await plugin.search(query, timeout, progress_callback)
|
||||
if not tracks:
|
||||
continue
|
||||
logger.info(f"{source_name} found {len(tracks)} tracks")
|
||||
return (tracks, albums)
|
||||
if tracks:
|
||||
logger.info(f"{source_name} found {len(tracks)} tracks")
|
||||
return (tracks, albums)
|
||||
except Exception as e:
|
||||
logger.warning(f"{source_name} search failed: {e}")
|
||||
|
||||
|
|
@ -430,75 +418,6 @@ class DownloadEngine:
|
|||
)
|
||||
return ([], [])
|
||||
|
||||
async def search_all_sources(self, query: str, source_chain,
|
||||
timeout=None, progress_callback=None,
|
||||
exclude_sources=None):
|
||||
"""Best-quality mode: pool RAW tracks from EVERY configured source in
|
||||
``source_chain`` instead of stopping at the first satisfying one.
|
||||
|
||||
Unlike :meth:`search_with_fallback`, no source short-circuits the
|
||||
search — the caller (orchestrator/worker) ranks the combined pool
|
||||
best→worst by actual audio quality. ``exclude_sources`` drops sources
|
||||
whose per-source retry budget is already spent (so their candidates
|
||||
never re-enter the pool). Unconfigured / unregistered / raising sources
|
||||
are skipped exactly like the fallback path. Returns
|
||||
``(combined_tracks, combined_albums)``.
|
||||
"""
|
||||
excluded = {s.lower() for s in (exclude_sources or []) if s}
|
||||
pooled_tracks = []
|
||||
pooled_albums = []
|
||||
# Per-source contribution for an honest pool log — e.g. a release-level
|
||||
# source like usenet/torrent that returns nothing for a track-title
|
||||
# query should read "usenet=0", not silently hide behind the chain name.
|
||||
contributions = []
|
||||
|
||||
# Decide which sources to actually query, recording why the rest were
|
||||
# skipped. Searches then run CONCURRENTLY so the pool waits only for the
|
||||
# slowest source (e.g. usenet/Prowlarr, which can be slow) rather than
|
||||
# the sum of every source's latency.
|
||||
to_search = [] # (source_name, plugin)
|
||||
for source_name in source_chain:
|
||||
if source_name.lower() in excluded:
|
||||
contributions.append(f"{source_name}=excluded")
|
||||
continue
|
||||
plugin = self._plugins.get(source_name)
|
||||
if plugin is None:
|
||||
logger.info(f"Skipping {source_name} (not available)")
|
||||
contributions.append(f"{source_name}=unavailable")
|
||||
continue
|
||||
if hasattr(plugin, 'is_configured') and not plugin.is_configured():
|
||||
logger.info(f"Skipping {source_name} (not configured)")
|
||||
contributions.append(f"{source_name}=unconfigured")
|
||||
continue
|
||||
to_search.append((source_name, plugin))
|
||||
|
||||
async def _one(plugin):
|
||||
return await plugin.search(query, timeout, progress_callback)
|
||||
|
||||
results = await asyncio.gather(
|
||||
*[_one(plugin) for _, plugin in to_search],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
for (source_name, _), result in zip(to_search, results, strict=True):
|
||||
if isinstance(result, Exception):
|
||||
logger.warning(f"{source_name} search failed: {result}")
|
||||
contributions.append(f"{source_name}=error")
|
||||
continue
|
||||
tracks, albums = result
|
||||
n = len(tracks) if tracks else 0
|
||||
if tracks:
|
||||
pooled_tracks.extend(tracks)
|
||||
if albums:
|
||||
pooled_albums.extend(albums)
|
||||
contributions.append(f"{source_name}={n}")
|
||||
|
||||
logger.info(
|
||||
"Best-quality pool: %d candidates [%s] for: %s",
|
||||
len(pooled_tracks), ', '.join(contributions), query,
|
||||
)
|
||||
return (pooled_tracks, pooled_albums)
|
||||
|
||||
async def download_with_fallback(self, username: str, filename: str,
|
||||
file_size: int, source_chain) -> Optional[str]:
|
||||
"""Try each source in ``source_chain`` until one accepts the
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ from config.settings import config_manager
|
|||
from core.download_engine import DownloadEngine
|
||||
from core.download_plugins.registry import DownloadPluginRegistry, build_default_registry
|
||||
from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus
|
||||
from core.quality.selection import load_search_mode
|
||||
|
||||
logger = get_logger("download_orchestrator")
|
||||
|
||||
|
|
@ -103,14 +102,12 @@ class DownloadOrchestrator:
|
|||
deezer_dl = self.client('deezer_dl')
|
||||
if deezer_arl and deezer_dl:
|
||||
deezer_dl.reconnect(deezer_arl)
|
||||
from core.quality.source_map import quality_tier_for_source
|
||||
deezer_dl._quality = quality_tier_for_source('deezer', default='flac')
|
||||
deezer_dl._quality = config_manager.get('deezer_download.quality', 'flac')
|
||||
|
||||
# Reload Amazon quality preference (T2Tunes needs no reconnect — public proxy)
|
||||
amazon = self.client('amazon')
|
||||
if amazon:
|
||||
from core.quality.source_map import quality_tier_for_source
|
||||
quality = quality_tier_for_source('amazon', default='flac')
|
||||
quality = config_manager.get('amazon_download.quality', 'flac')
|
||||
amazon._quality = quality
|
||||
amazon._allow_fallback = config_manager.get('amazon_download.allow_fallback', True)
|
||||
if hasattr(amazon, '_client') and amazon._client:
|
||||
|
|
@ -143,10 +140,7 @@ class DownloadOrchestrator:
|
|||
continue
|
||||
if hasattr(client, 'download_path') and client.download_path != new_path:
|
||||
client.download_path = new_path
|
||||
try:
|
||||
client.download_path.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
logger.warning(f"Could not verify download path {new_path}: {e}")
|
||||
client.download_path.mkdir(parents=True, exist_ok=True)
|
||||
# YouTube also caches path in yt-dlp opts
|
||||
if hasattr(client, 'download_opts') and 'outtmpl' in client.download_opts:
|
||||
client.download_opts['outtmpl'] = str(new_path / '%(title)s.%(ext)s')
|
||||
|
|
@ -348,11 +342,6 @@ class DownloadOrchestrator:
|
|||
if not chain:
|
||||
logger.warning("Hybrid search exhausted: no eligible sources after exclusion filter")
|
||||
return [], []
|
||||
if load_search_mode() == 'best_quality':
|
||||
logger.info(f"Best-quality search ({' → '.join(chain)}): {query}")
|
||||
return await self.engine.search_all_sources(
|
||||
query, chain, timeout, progress_callback,
|
||||
)
|
||||
logger.info(f"Hybrid search ({' → '.join(chain)}): {query}")
|
||||
return await self.engine.search_with_fallback(query, chain, timeout, progress_callback)
|
||||
|
||||
|
|
@ -423,17 +412,9 @@ class DownloadOrchestrator:
|
|||
|
||||
if scored:
|
||||
scored.sort(key=lambda x: x._match_confidence, reverse=True)
|
||||
# Match filter done (right track); now prefer the best quality
|
||||
# among the confidence-passing survivors so streaming isn't
|
||||
# quality-blind like Soulseek already isn't. Stable ranking
|
||||
# keeps confidence order within an equal quality tier; the
|
||||
# `or scored` fail-safe never leaves us with nothing to try.
|
||||
from core.quality.selection import rank_for_profile
|
||||
ranked, _ = rank_for_profile(scored)
|
||||
filtered_results = ranked or scored
|
||||
filtered_results = scored
|
||||
logger.info(f"Streaming validation: {len(scored)}/{len(tracks)} passed "
|
||||
f"(best: {scored[0]._match_confidence:.2f}, "
|
||||
f"quality pick: {filtered_results[0].audio_quality.label()})")
|
||||
f"(best: {scored[0]._match_confidence:.2f})")
|
||||
else:
|
||||
logger.warning(f"No streaming results passed validation for: {query}")
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -21,10 +21,8 @@ folder scan.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
import unicodedata
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Optional
|
||||
|
|
@ -34,13 +32,6 @@ from utils.logging_config import get_logger
|
|||
|
||||
logger = get_logger("download_plugins.album_bundle")
|
||||
|
||||
# Minimum album-title relevance a Prowlarr candidate must clear to be eligible
|
||||
# for an album-bundle download (#730). Prowlarr returns broad fuzzy matches — a
|
||||
# "Heroes" search also returns other Bowie albums — so without this gate the
|
||||
# most-popular result wins regardless of whether it's the right album. Below
|
||||
# this floor we refuse the bundle and let the caller fall back to per-track.
|
||||
_ALBUM_TITLE_RELEVANCE_FLOOR = 0.6
|
||||
|
||||
|
||||
# Album-pick size floor / ceiling. Single-track torrents (~10 MB)
|
||||
# are rejected when bigger candidates exist; anything past 3 GB is
|
||||
|
|
@ -101,96 +92,10 @@ def quality_score(title: str, quality_guess) -> int:
|
|||
return _QUALITY_SCORE.get(quality_guess(title) or '', 0)
|
||||
|
||||
|
||||
def _normalize_release_text(text: str) -> str:
|
||||
"""Lowercase, fold accents (Björk -> bjork), strip punctuation to spaces.
|
||||
|
||||
NFKD-decompose then drop combining marks so accented characters fold to
|
||||
their base letter instead of fragmenting (the naive approach turned
|
||||
'Björk' into 'bj rk'). Collapses runs of whitespace.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
decomposed = unicodedata.normalize("NFKD", text)
|
||||
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
|
||||
lowered = stripped.lower()
|
||||
# Punctuation -> space (so "heroes" matches "heroes:" / "heroes -"),
|
||||
# then collapse whitespace.
|
||||
cleaned = re.sub(r"[^a-z0-9]+", " ", lowered)
|
||||
return re.sub(r"\s+", " ", cleaned).strip()
|
||||
|
||||
|
||||
# Edition / format / qualifier words that appear in stored album names or
|
||||
# release titles but say nothing about WHICH album it is. Stripped before
|
||||
# scoring so "Currents" matches "Currents (Deluxe)" and "Heroes" matches
|
||||
# "Heroes (2017 Remaster)" — the #730 fix must not reject the RIGHT album just
|
||||
# because the DB name carries an edition suffix the torrent title lacks.
|
||||
_ALBUM_NOISE_WORDS = frozenset({
|
||||
"deluxe", "edition", "remaster", "remastered", "remasters", "remix",
|
||||
"expanded", "anniversary", "bonus", "version", "explicit", "clean",
|
||||
"reissue", "special", "limited", "collectors", "collector", "the",
|
||||
"ep", "lp", "album", "single", "disc", "cd", "vol", "volume",
|
||||
"flac", "mp3", "aac", "ogg", "wav", "alac", "m4a", "320", "256", "192",
|
||||
"web", "vinyl", "hi", "res", "hires", "24bit", "16bit", "original",
|
||||
"soundtrack", "ost",
|
||||
})
|
||||
|
||||
|
||||
def _significant_words(normalized: str) -> list:
|
||||
"""Words that actually identify an album: drop pure-digit tokens (years,
|
||||
bitrates) and edition/format noise. Keeps at least the raw words if the
|
||||
filter would empty it (e.g. an album literally named '1989' or 'Deluxe')."""
|
||||
words = [w for w in normalized.split()
|
||||
if w not in _ALBUM_NOISE_WORDS and not w.isdigit()]
|
||||
return words or normalized.split()
|
||||
|
||||
|
||||
def album_title_relevance(candidate_title: str, album_name: str) -> float:
|
||||
"""How well a release title matches the requested album, 0.0–1.0.
|
||||
|
||||
Scores the fraction of the album's SIGNIFICANT words (edition/format/year
|
||||
noise removed) that appear as whole words in the candidate title.
|
||||
Word-boundary, not substring, so "Heroes" does NOT match "Superheroes" and
|
||||
a different album sharing no significant words scores 0 — while "Currents"
|
||||
still matches "Currents (Deluxe)" and "Heroes" matches the "2017 Remaster".
|
||||
|
||||
Returns 1.0 when there's no album name to check (can't gate on nothing —
|
||||
preserves old behavior for callers that don't pass a title).
|
||||
"""
|
||||
norm_album = _normalize_release_text(album_name)
|
||||
if not norm_album:
|
||||
return 1.0
|
||||
norm_title = _normalize_release_text(candidate_title)
|
||||
if not norm_title:
|
||||
return 0.0
|
||||
album_words = _significant_words(norm_album)
|
||||
title_words = set(norm_title.split())
|
||||
if not album_words:
|
||||
return 1.0
|
||||
matched = sum(1 for w in album_words if w in title_words)
|
||||
coverage = matched / len(album_words)
|
||||
# Full-phrase bonus (idea from contributor PR #731): when the album's core
|
||||
# phrase appears intact in the title, we're highly confident it's the right
|
||||
# release even if token-coverage is dragged down by a long multi-word name.
|
||||
# MUST be word-boundary anchored, NOT a raw substring — a naive
|
||||
# `phrase in norm_title` lets "heroes" match "superheroes" and reintroduces
|
||||
# the exact wrong-album bug #730 fixes (PR #731's version has this flaw).
|
||||
core_phrase = " ".join(album_words)
|
||||
if core_phrase and re.search(rf"(?:^| ){re.escape(core_phrase)}(?: |$)", norm_title):
|
||||
coverage = max(coverage, 0.9)
|
||||
return coverage
|
||||
|
||||
|
||||
def pick_best_album_release(candidates, quality_guess,
|
||||
album_name: str = "") -> Optional[object]:
|
||||
def pick_best_album_release(candidates, quality_guess) -> Optional[object]:
|
||||
"""Pick the single best torrent / NZB for an album-bundle download.
|
||||
|
||||
Heuristic, in priority order:
|
||||
0. Album-TITLE relevance gate (#730): drop candidates whose title doesn't
|
||||
sufficiently match the requested album. Prowlarr returns broad fuzzy
|
||||
matches, so without this the most-popular result wins even when it's a
|
||||
different album. When ``album_name`` is given and NOTHING clears the
|
||||
relevance floor, return None — the caller then falls back to per-track
|
||||
rather than downloading a confident mismatch.
|
||||
1. Reasonable album-ish size (40 MB – 3 GB) — drops single-track
|
||||
releases that snuck in and quarantines suspicious giants.
|
||||
2. Higher seeders > lower (dead torrents = dead downloads).
|
||||
|
|
@ -201,24 +106,6 @@ def pick_best_album_release(candidates, quality_guess,
|
|||
"""
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# 0. Title-relevance gate. Only applied when we know the album name; with
|
||||
# no name we can't judge relevance, so we don't gate (old behavior).
|
||||
if album_name:
|
||||
relevant = [
|
||||
c for c in candidates
|
||||
if album_title_relevance(c.title or "", album_name) >= _ALBUM_TITLE_RELEVANCE_FLOOR
|
||||
]
|
||||
if not relevant:
|
||||
logger.warning(
|
||||
"[Album Bundle] No candidate cleared the title-relevance floor "
|
||||
"for '%s' (%d candidates rejected as wrong album) — refusing the "
|
||||
"bundle so the caller falls back to per-track.",
|
||||
album_name, len(candidates),
|
||||
)
|
||||
return None
|
||||
candidates = relevant
|
||||
|
||||
sized = [c for c in candidates
|
||||
if ALBUM_PICK_MIN_BYTES <= (c.size or 0) <= ALBUM_PICK_MAX_BYTES]
|
||||
pool = sized or list(candidates)
|
||||
|
|
@ -706,19 +593,13 @@ def resolve_reported_save_path(
|
|||
|
||||
|
||||
def copy_audio_files_atomically(
|
||||
sources: Iterable[Path], staging_dir: Path, remove_source: bool = False,
|
||||
sources: Iterable[Path], staging_dir: Path,
|
||||
) -> list:
|
||||
"""Convenience wrapper: pick a non-colliding staging path for
|
||||
each source, copy via ``atomic_copy_to_staging``. Returns the
|
||||
list of final destination paths (as strings). Files that fail
|
||||
to copy are logged and skipped; the caller decides what to do
|
||||
with a partial result.
|
||||
|
||||
``remove_source=True`` deletes each source AFTER it copies
|
||||
successfully — used by the Soulseek bundle path so slskd's
|
||||
completed downloads don't pile up in its download folder (#796).
|
||||
Kept False for torrent/usenet, whose clients must retain the
|
||||
originals (seeding / client-managed)."""
|
||||
with a partial result."""
|
||||
staging_dir.mkdir(parents=True, exist_ok=True)
|
||||
out: list = []
|
||||
for src in sources:
|
||||
|
|
@ -726,14 +607,6 @@ def copy_audio_files_atomically(
|
|||
try:
|
||||
atomic_copy_to_staging(src, dest)
|
||||
out.append(str(dest))
|
||||
if remove_source:
|
||||
# Only after a verified copy — never lose data on a failed stage.
|
||||
try:
|
||||
Path(src).unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("[album_bundle] Could not remove staged source %s: %s", src, e)
|
||||
except Exception as e:
|
||||
logger.warning("[album_bundle] Failed to stage %s -> %s: %s", src, dest, e)
|
||||
return out
|
||||
|
|
|
|||
|
|
@ -68,11 +68,6 @@ from core.download_plugins.album_bundle import (
|
|||
resolve_reported_save_path,
|
||||
)
|
||||
from core.download_plugins.base import DownloadSourcePlugin
|
||||
from core.download_plugins.torrent_stall import (
|
||||
StallTracker,
|
||||
get_stall_action,
|
||||
get_stall_timeout,
|
||||
)
|
||||
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
|
||||
from core.prowlarr_client import (
|
||||
DEFAULT_MUSIC_CATEGORIES,
|
||||
|
|
@ -310,11 +305,6 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
|
|||
# but the same tolerance keeps a one-off connection failure
|
||||
# from killing an otherwise-healthy download.
|
||||
misses = TransientMissCounter()
|
||||
# Stalled-torrent handling (noldevin): give up early on a torrent
|
||||
# making zero progress (dead magnet stuck on metadata, no seeders)
|
||||
# instead of holding this worker for the full album deadline. Read
|
||||
# per-download so a settings change applies to in-flight torrents.
|
||||
stall = StallTracker(get_stall_timeout())
|
||||
while time.monotonic() < deadline:
|
||||
if self.shutdown_check and self.shutdown_check():
|
||||
return
|
||||
|
|
@ -352,66 +342,13 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
|
|||
self._finalize_download(download_id, last_save_path)
|
||||
return
|
||||
if status.state == 'error':
|
||||
# Clean the dead torrent out of the client, or it's left orphaned
|
||||
# (active in qbit, untracked here) and re-grabbed as a duplicate.
|
||||
self._cleanup_torrent(torrent_hash, get_stall_action())
|
||||
self._mark_error(download_id, status.error or "Torrent client reported error")
|
||||
return
|
||||
|
||||
if stall.is_stalled(status.downloaded, status.state, time.monotonic(),
|
||||
size=status.size):
|
||||
self._handle_stalled(download_id, torrent_hash, get_stall_action())
|
||||
return
|
||||
|
||||
time.sleep(_POLL_INTERVAL_SECONDS)
|
||||
|
||||
# Deadline reached. One last status check closes the race where the
|
||||
# torrent completed during the final poll interval — finalize it instead
|
||||
# of deleting a just-finished download's files. Otherwise clean it out of
|
||||
# the client, or it sits orphaned in qbit (e.g. a metadata-stuck magnet
|
||||
# that escaped the stall timer) and gets re-grabbed as a duplicate.
|
||||
try:
|
||||
final = run_async(adapter.get_status(torrent_hash))
|
||||
except Exception:
|
||||
final = None
|
||||
if final is not None and final.state in _COMPLETE_STATES:
|
||||
self._finalize_download(download_id, final.save_path or last_save_path)
|
||||
return
|
||||
self._cleanup_torrent(torrent_hash, get_stall_action())
|
||||
self._mark_error(download_id, "Torrent download timed out")
|
||||
|
||||
def _cleanup_torrent(self, torrent_hash: str, action: str) -> None:
|
||||
"""Remove (abandon) or pause a dead/stalled/timed-out torrent in the
|
||||
client so it isn't left ORPHANED — active in qbit but no longer tracked
|
||||
here, which makes SoulSync re-grab the same dead torrent as a duplicate
|
||||
on the next attempt (noldevin). Best-effort: a client error is logged,
|
||||
not raised, so the download still fails cleanly."""
|
||||
adapter = get_active_torrent_adapter()
|
||||
if adapter is None or not torrent_hash:
|
||||
return
|
||||
try:
|
||||
if action == "pause":
|
||||
run_async(adapter.pause(torrent_hash))
|
||||
else:
|
||||
# delete_files: a stalled/failed torrent's partial data is junk
|
||||
# (often just a metadata stub) — don't leave it on disk.
|
||||
run_async(adapter.remove(torrent_hash, delete_files=True))
|
||||
except Exception as e:
|
||||
logger.warning("Torrent cleanup (%s) on %s failed: %s",
|
||||
action, torrent_hash[:8] if torrent_hash else "?", e)
|
||||
|
||||
def _handle_stalled(self, download_id: str, torrent_hash: str, action: str) -> None:
|
||||
"""A torrent made no progress past the stall timeout. Abandon it
|
||||
(remove from client + delete its partial data) or pause it for the
|
||||
user, then fail the download so the worker frees up."""
|
||||
timeout_min = round(get_stall_timeout() / 60, 1)
|
||||
self._cleanup_torrent(torrent_hash, action)
|
||||
verb = "paused" if action == "pause" else "removed"
|
||||
self._mark_error(
|
||||
download_id,
|
||||
f"Torrent stalled (no progress for {timeout_min} min) — {verb}",
|
||||
)
|
||||
|
||||
def _finalize_download(self, download_id: str, save_path: Optional[str]) -> None:
|
||||
"""Adapter said complete. Walk the directory + pick the
|
||||
first audio file as the canonical ``file_path``."""
|
||||
|
|
@ -557,25 +494,12 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
|
|||
candidates = [r for r in search_results
|
||||
if r.protocol == 'torrent' and (r.magnet_uri or r.download_url)]
|
||||
if not candidates:
|
||||
# Album isn't available on this source. Mark the failure as
|
||||
# fallback-eligible so the dispatch returns to the per-track flow
|
||||
# instead of hard-failing the batch — in hybrid mode that lets the
|
||||
# next configured source take over. Without this flag a torrent-first
|
||||
# hybrid would get stuck at "searching" forever when Prowlarr
|
||||
# returns nothing, never trying the other sources.
|
||||
result['error'] = f'No torrent results found for "{query}"'
|
||||
result['fallback'] = True
|
||||
return result
|
||||
|
||||
picked = pick_best_album_release(
|
||||
candidates, _guess_quality_from_title, album_name=album_name,
|
||||
)
|
||||
picked = pick_best_album_release(candidates, _guess_quality_from_title)
|
||||
if picked is None:
|
||||
# No candidate matched the requested album (or none passed filtering).
|
||||
# Fall back to the per-track flow rather than downloading a wrong
|
||||
# album (#730) — per-track searches each track individually.
|
||||
result['error'] = 'No torrent candidate matched the requested album'
|
||||
result['fallback'] = True
|
||||
result['error'] = 'No suitable torrent candidate after filtering'
|
||||
return result
|
||||
|
||||
download_url = picked.magnet_uri or picked.download_url
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
"""Stalled-torrent detection + policy (noldevin's request).
|
||||
|
||||
A torrent can sit forever making zero progress — most commonly stuck
|
||||
"downloading metadata" on a magnet with no peers, but also a dead swarm
|
||||
mid-download. The torrent poll loop would just burn the full 6-hour album
|
||||
timeout on it. This module decides, from the live status stream, when a
|
||||
torrent has been stalled too long, and what to do about it.
|
||||
|
||||
Design split, kept testable:
|
||||
- ``StallTracker`` is the pure decision core — feed it each poll's
|
||||
``(downloaded, state, now)`` and it answers "stalled too long?" using a
|
||||
monotonic clock passed in (no time import, no I/O). Progress = bytes
|
||||
moved since the last poll; any forward movement resets the stall clock.
|
||||
Terminal/healthy-but-idle states (seeding, completed, paused) never count
|
||||
as stalled — only states where the torrent is *supposed* to be working.
|
||||
- ``get_stall_timeout`` / ``get_stall_action`` read the two settings.
|
||||
|
||||
A timeout of 0 disables stall handling entirely (back to the old behavior:
|
||||
ride the full poll deadline).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from config.settings import config_manager
|
||||
|
||||
# 0 = disabled. 10 minutes is long enough to ride out a slow metadata fetch
|
||||
# or a brief peer drought, short enough to give up on a truly dead magnet
|
||||
# instead of holding a worker for 6 hours.
|
||||
DEFAULT_STALL_TIMEOUT_SECONDS = 10 * 60
|
||||
|
||||
# What to do when a torrent stalls past the timeout:
|
||||
# 'abandon' — remove it from the client (and its partial data) + fail the
|
||||
# download so the worker is freed and the next source can try.
|
||||
# 'pause' — pause it in the client + fail the download, leaving the
|
||||
# torrent for the user to inspect/resume manually.
|
||||
_VALID_ACTIONS = ("abandon", "pause")
|
||||
DEFAULT_STALL_ACTION = "abandon"
|
||||
|
||||
# States where the torrent is meant to be making download progress, so a
|
||||
# lack of it counts toward the stall clock. Mirrors the adapter-uniform set
|
||||
# in core/torrent_clients/base.py. Notably EXCLUDES seeding/completed (done)
|
||||
# and paused (the user's own choice) — neither is a stall.
|
||||
STALLABLE_STATES = frozenset(("queued", "downloading", "stalled", "error"))
|
||||
|
||||
|
||||
def get_stall_timeout() -> float:
|
||||
"""Seconds of zero progress before a torrent is considered stalled.
|
||||
0 (or invalid/negative) disables stall handling."""
|
||||
raw = config_manager.get("download_source.torrent_stall_timeout_seconds",
|
||||
DEFAULT_STALL_TIMEOUT_SECONDS)
|
||||
try:
|
||||
value = float(raw)
|
||||
if value >= 0:
|
||||
return value
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return DEFAULT_STALL_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def get_stall_action() -> str:
|
||||
"""What to do with a stalled torrent: 'abandon' (default) or 'pause'."""
|
||||
raw = config_manager.get("download_source.torrent_stall_action",
|
||||
DEFAULT_STALL_ACTION)
|
||||
action = str(raw or "").strip().lower()
|
||||
return action if action in _VALID_ACTIONS else DEFAULT_STALL_ACTION
|
||||
|
||||
|
||||
class StallTracker:
|
||||
"""Tracks one torrent's forward progress across polls.
|
||||
|
||||
Pure + clock-injected so it tests without sleeping. ``timeout`` <= 0
|
||||
disables it (``is_stalled`` always returns False)."""
|
||||
|
||||
def __init__(self, timeout_seconds: float):
|
||||
self.timeout = float(timeout_seconds or 0)
|
||||
self._last_downloaded = -1 # -1 = first observation
|
||||
self._had_metadata = None # None = first observation; else size>0?
|
||||
self._progress_since = None # monotonic time of last forward movement
|
||||
|
||||
def is_stalled(self, downloaded: int, state: str, now: float,
|
||||
size: int = None) -> bool:
|
||||
"""Record this poll's observation; return True iff the torrent has gone
|
||||
``timeout`` seconds with no real forward progress while in a working state.
|
||||
|
||||
``downloaded`` is cumulative payload bytes; ``state`` is the adapter-uniform
|
||||
state; ``now`` is a monotonic timestamp; ``size`` is the torrent's total
|
||||
size in bytes (0/None while still fetching metadata).
|
||||
|
||||
Metadata-phase fix (#852-adjacent torrent report): a magnet stuck
|
||||
"downloading metadata" reports ``size==0`` and a ``downloaded`` byte
|
||||
counter that still ticks up from DHT/peer-protocol overhead even though it
|
||||
makes no actual progress. Treating those bumps as progress reset the stall
|
||||
clock forever, so a dead magnet never timed out. Now the byte counter only
|
||||
counts once metadata is in (``size>0``); during the metadata phase the only
|
||||
thing that counts as progress is *obtaining* the metadata, so a torrent
|
||||
that can't even do that within the timeout is correctly flagged stalled.
|
||||
"""
|
||||
if self.timeout <= 0:
|
||||
return False
|
||||
|
||||
downloaded = int(downloaded or 0)
|
||||
# size is None when the caller doesn't track it (assume metadata present —
|
||||
# the old byte-progress behavior); an explicit size==0 is the metadata
|
||||
# phase (metaDL), where the byte counter is unreliable noise.
|
||||
has_metadata = size is None or int(size) > 0
|
||||
|
||||
# Real forward progress: first sighting, metadata just arrived, or (only
|
||||
# once we have metadata) more payload bytes. Byte bumps during the
|
||||
# metadata phase are protocol noise and do NOT count.
|
||||
progressed = (
|
||||
self._had_metadata is None # first poll
|
||||
or (has_metadata and not self._had_metadata) # got metadata
|
||||
or (has_metadata and downloaded > self._last_downloaded) # more payload
|
||||
)
|
||||
self._had_metadata = has_metadata
|
||||
self._last_downloaded = downloaded
|
||||
|
||||
if progressed:
|
||||
self._progress_since = now
|
||||
return False
|
||||
|
||||
# Not in a working state → not a stall (seeding/paused/completed).
|
||||
if state not in STALLABLE_STATES:
|
||||
self._progress_since = now # don't accrue stall time while idle-by-design
|
||||
return False
|
||||
|
||||
if self._progress_since is None:
|
||||
self._progress_since = now
|
||||
return False
|
||||
|
||||
return (now - self._progress_since) >= self.timeout
|
||||
|
|
@ -13,11 +13,10 @@ import from a neutral package per Cin's contract-first standard.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.imports.filename import parse_filename_metadata
|
||||
from core.quality.model import AudioQuality
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -33,36 +32,6 @@ class SearchResult:
|
|||
upload_speed: int
|
||||
queue_length: int
|
||||
result_type: str = "track" # "track" or "album"
|
||||
# Rich quality metadata — populated by sources that provide it.
|
||||
# None means "unknown", not "absent".
|
||||
sample_rate: Optional[int] = None # Hz (e.g. 44100, 96000, 192000)
|
||||
bit_depth: Optional[int] = None # bits per sample (16, 24)
|
||||
|
||||
@property
|
||||
def audio_quality(self) -> AudioQuality:
|
||||
"""Unified quality descriptor derived from this result's fields."""
|
||||
return AudioQuality(
|
||||
format=self.quality.lower() if self.quality else 'unknown',
|
||||
bitrate=self.bitrate,
|
||||
sample_rate=self.sample_rate,
|
||||
bit_depth=self.bit_depth,
|
||||
)
|
||||
|
||||
def set_quality(self, aq: AudioQuality) -> None:
|
||||
"""Merge a mapped :class:`AudioQuality` onto this result's fields.
|
||||
|
||||
Used by streaming sources to stamp their claimed tier (Tidal/HiFi
|
||||
tier strings, Qobuz API values, …) so ``audio_quality`` ranks
|
||||
correctly. Mapper-provided fields win; a ``None`` from the mapper
|
||||
leaves any already-reported value (e.g. a probed bitrate) intact.
|
||||
"""
|
||||
self.quality = aq.format
|
||||
if aq.bitrate is not None:
|
||||
self.bitrate = aq.bitrate
|
||||
if aq.sample_rate is not None:
|
||||
self.sample_rate = aq.sample_rate
|
||||
if aq.bit_depth is not None:
|
||||
self.bit_depth = aq.bit_depth
|
||||
|
||||
@property
|
||||
def quality_score(self) -> float:
|
||||
|
|
@ -158,19 +127,6 @@ class AlbumResult:
|
|||
queue_length: int = 0
|
||||
result_type: str = "album"
|
||||
|
||||
@property
|
||||
def audio_quality(self) -> AudioQuality:
|
||||
"""Unified quality descriptor derived from dominant track quality."""
|
||||
sample_rates = [t.sample_rate for t in self.tracks if t.sample_rate]
|
||||
bit_depths = [t.bit_depth for t in self.tracks if t.bit_depth]
|
||||
bitrates = [t.bitrate for t in self.tracks if t.bitrate]
|
||||
return AudioQuality(
|
||||
format=self.dominant_quality.lower() if self.dominant_quality else 'unknown',
|
||||
bitrate=max(bitrates) if bitrates else None,
|
||||
sample_rate=max(sample_rates) if sample_rates else None,
|
||||
bit_depth=max(bit_depths) if bit_depths else None,
|
||||
)
|
||||
|
||||
@property
|
||||
def quality_score(self) -> float:
|
||||
"""Calculate album quality score based on dominant quality and track count"""
|
||||
|
|
|
|||
|
|
@ -465,22 +465,12 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
|
|||
candidates = [r for r in search_results
|
||||
if r.protocol == 'usenet' and r.download_url]
|
||||
if not candidates:
|
||||
# Album isn't available on this source — fall back to the per-track
|
||||
# flow (next configured source in hybrid mode) rather than hard-
|
||||
# failing the whole batch. Mirrors the torrent plugin + soulseek's
|
||||
# default fallback contract.
|
||||
result['error'] = f'No usenet results found for "{query}"'
|
||||
result['fallback'] = True
|
||||
return result
|
||||
|
||||
picked = pick_best_album_release(
|
||||
candidates, _guess_quality_from_title, album_name=album_name,
|
||||
)
|
||||
picked = pick_best_album_release(candidates, _guess_quality_from_title)
|
||||
if picked is None:
|
||||
# No candidate matched the requested album (or none passed filtering).
|
||||
# Fall back to per-track rather than grabbing a wrong album (#730).
|
||||
result['error'] = 'No NZB candidate matched the requested album'
|
||||
result['fallback'] = True
|
||||
result['error'] = 'No suitable NZB candidate after filtering'
|
||||
return result
|
||||
|
||||
logger.info("[Usenet album] Picked '%s' (size=%.1fMB grabs=%s indexer=%s)",
|
||||
|
|
|
|||
|
|
@ -173,22 +173,7 @@ def try_dispatch(
|
|||
)
|
||||
except Exception as exc:
|
||||
logger.exception("[Album Bundle] %s plugin raised: %s", mode, exc)
|
||||
# An OSError means an I/O step failed after the source already had the
|
||||
# album — most importantly the staging dir not being writable (#760),
|
||||
# but also any transient filesystem error. Treat it as fallback-eligible
|
||||
# so we return to the per-track flow instead of hard-failing the whole
|
||||
# batch (the #715 symptom: files download, then the batch fails).
|
||||
# Programming errors (TypeError, KeyError, …) are NOT OSError and stay
|
||||
# terminal, so genuine bugs still fail loudly. (requests' network
|
||||
# exceptions also subclass OSError, but plugins normally catch those
|
||||
# internally and return an outcome rather than raising; if one does
|
||||
# surface here, falling back to per-track is still the safe choice.)
|
||||
is_io_failure = isinstance(exc, OSError)
|
||||
outcome = {
|
||||
'success': False,
|
||||
'error': f'Plugin error: {exc}',
|
||||
'fallback': is_io_failure,
|
||||
}
|
||||
outcome = {'success': False, 'error': f'Plugin error: {exc}'}
|
||||
|
||||
if not outcome.get('success'):
|
||||
err = outcome.get('error', 'Album bundle download failed')
|
||||
|
|
|
|||
|
|
@ -83,26 +83,9 @@ def clear_completed_local() -> int:
|
|||
"""
|
||||
cleared = 0
|
||||
with tasks_lock:
|
||||
# Protect tasks belonging to a still-active batch. A batch is "active"
|
||||
# while any of its queued tasks is non-terminal (still searching /
|
||||
# downloading / queued / post-processing). Pruning a batch's completed
|
||||
# or failed tasks mid-run would yank them out of the Downloads page —
|
||||
# and failed/cancelled rows aren't recoverable from library_history —
|
||||
# so the user would never see them until the batch ended. Keep the whole
|
||||
# active batch intact; it gets cleaned by a later run once it finishes.
|
||||
protected_task_ids: set = set()
|
||||
for batch in download_batches.values():
|
||||
queue = batch.get('queue', []) if isinstance(batch, dict) else []
|
||||
batch_active = any(
|
||||
download_tasks.get(tid, {}).get('status') not in _TERMINAL_STATUSES
|
||||
for tid in queue if tid in download_tasks
|
||||
)
|
||||
if batch_active:
|
||||
protected_task_ids.update(queue)
|
||||
|
||||
task_ids_to_remove = [
|
||||
tid for tid, task in download_tasks.items()
|
||||
if task.get('status') in _TERMINAL_STATUSES and tid not in protected_task_ids
|
||||
if task.get('status') in _TERMINAL_STATUSES
|
||||
]
|
||||
for tid in task_ids_to_remove:
|
||||
del download_tasks[tid]
|
||||
|
|
|
|||
|
|
@ -47,55 +47,6 @@ from core.runtime_state import (
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _priority_sort_key(r):
|
||||
"""Today's confidence-first key: never download a high-quality WRONG file."""
|
||||
return (
|
||||
getattr(r, 'confidence', 0) or 0,
|
||||
getattr(r, 'quality_score', 0) or 0,
|
||||
getattr(r, 'upload_speed', 0) or 0,
|
||||
-(getattr(r, 'queue_length', 0) or 0),
|
||||
getattr(r, 'free_upload_slots', 0) or 0,
|
||||
getattr(r, 'size', 0) or 0,
|
||||
)
|
||||
|
||||
|
||||
def _quality_first_sort_key(r, targets):
|
||||
"""Best-quality key: the user's profile quality rank dominates; all the
|
||||
priority-mode signals (confidence, speed, …) become tiebreakers.
|
||||
|
||||
Every candidate reaching this point already passed match filtering, so it
|
||||
is "correct enough" — ordering by quality among correct candidates is safe.
|
||||
Candidates with no usable quality info, or that match no target, sort last
|
||||
(never dropped). Lower target index = better target, so it's negated to fit
|
||||
the descending (reverse=True) sort.
|
||||
"""
|
||||
from core.quality.model import rank_candidate
|
||||
|
||||
aq = getattr(r, 'audio_quality', None)
|
||||
if aq is None or not targets:
|
||||
target_idx, tier = (len(targets) if targets else 0), 0.0
|
||||
else:
|
||||
try:
|
||||
target_idx, tier = rank_candidate(aq, targets)
|
||||
except Exception:
|
||||
target_idx, tier = len(targets), 0.0
|
||||
return (-target_idx, tier) + _priority_sort_key(r)
|
||||
|
||||
|
||||
def order_candidates(candidates, *, quality_first=False, targets=None):
|
||||
"""Return *candidates* ordered best-first for the download walk.
|
||||
|
||||
``quality_first=False`` (priority mode) → confidence-first, byte-for-byte
|
||||
today's behaviour. ``quality_first=True`` (best-quality mode) → the user's
|
||||
profile quality rank dominates, confidence/peer signals break ties.
|
||||
"""
|
||||
if quality_first:
|
||||
key = lambda r: _quality_first_sort_key(r, targets or [])
|
||||
else:
|
||||
key = _priority_sort_key
|
||||
return sorted(candidates, key=key, reverse=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CandidatesDeps:
|
||||
"""Bundle of cross-cutting deps the candidate-fallback logic needs."""
|
||||
|
|
@ -108,25 +59,25 @@ class CandidatesDeps:
|
|||
on_download_completed: Callable
|
||||
|
||||
|
||||
def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
|
||||
deps: CandidatesDeps = None, *,
|
||||
quality_first=False, quality_targets=None):
|
||||
def attempt_download_with_candidates(task_id, candidates, track, batch_id=None, deps: CandidatesDeps = None):
|
||||
"""
|
||||
Attempts to download with fallback candidate logic (matches GUI's retry_parallel_download_with_fallback).
|
||||
Returns True if successful, False if all candidates fail.
|
||||
|
||||
``quality_first`` (best-quality search mode) orders the walk by the user's
|
||||
profile quality rank instead of confidence-first; ``quality_targets`` is the
|
||||
profile target list used for that ranking. Defaults preserve priority-mode
|
||||
behaviour exactly.
|
||||
"""
|
||||
# Sort candidates. Priority mode: confidence-first, then peer quality —
|
||||
# upstream Soulseek validation already considers peer speed/slots/queue when
|
||||
# scores are close; preserve that signal instead of flattening ties back to
|
||||
# arbitrary slskd response order. Best-quality mode: profile quality rank
|
||||
# dominates (all candidates here already passed match filtering).
|
||||
candidates = order_candidates(
|
||||
candidates, quality_first=quality_first, targets=quality_targets,
|
||||
# Sort candidates by match confidence first, then peer quality. Upstream
|
||||
# Soulseek validation already considers peer speed/slots/queue when scores
|
||||
# are close; preserve that signal here instead of flattening ties back to
|
||||
# arbitrary slskd response order.
|
||||
candidates.sort(
|
||||
key=lambda r: (
|
||||
getattr(r, 'confidence', 0) or 0,
|
||||
getattr(r, 'quality_score', 0) or 0,
|
||||
getattr(r, 'upload_speed', 0) or 0,
|
||||
-(getattr(r, 'queue_length', 0) or 0),
|
||||
getattr(r, 'free_upload_slots', 0) or 0,
|
||||
getattr(r, 'size', 0) or 0,
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
with tasks_lock:
|
||||
|
|
@ -254,21 +205,6 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
|
|||
'artists': _fallback_album_artists
|
||||
}
|
||||
|
||||
# #915: parity with Reorganize / manual Enrich. If the album context is lean
|
||||
# (no release_date) and the user's PRIMARY metadata source isn't Spotify, hydrate
|
||||
# it from that source — the same place a reorganize reads — so the download's
|
||||
# $year folder, release_date and album_type match instead of dropping the year /
|
||||
# defaulting to YYYY-01-01 and forcing a manual reorganize afterwards.
|
||||
try:
|
||||
from core.downloads.track_metadata_backfill import backfill_album_context_from_source
|
||||
from core.metadata import registry as _meta_registry
|
||||
from core.metadata.album_tracks import get_album_for_source as _get_album_for_source
|
||||
backfill_album_context_from_source(
|
||||
spotify_album_context, _meta_registry.get_primary_source(), _get_album_for_source,
|
||||
)
|
||||
except Exception as _bf_err: # noqa: BLE001 — never let backfill break a download
|
||||
logger.debug("[Context] primary-source album backfill skipped: %s", _bf_err)
|
||||
|
||||
download_payload = candidate.__dict__
|
||||
|
||||
username = download_payload.get('username')
|
||||
|
|
@ -390,19 +326,9 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
|
|||
"task=%s username=%s filename=%s",
|
||||
task_id, username, os.path.basename(filename),
|
||||
)
|
||||
elif track_info and track_info.get('_skip_acoustid'):
|
||||
# Issue #797 — the album-download request had the
|
||||
# per-request "Skip AcoustID verification" toggle on.
|
||||
# Bypass only the AcoustID gate (same as a manual
|
||||
# pick); integrity + bit-depth still run.
|
||||
matched_downloads_context[context_key]['_skip_quarantine_check'] = 'acoustid'
|
||||
logger.info(
|
||||
"[Context] Skip-AcoustID toggle — bypassing AcoustID for "
|
||||
"task=%s filename=%s",
|
||||
task_id, os.path.basename(filename),
|
||||
)
|
||||
|
||||
logger.info(f"[Context] Set is_album_download: {is_album_context} (has clean data: {has_clean_spotify_data})")
|
||||
logger.debug(f"[Debug] Context creation - track_info: {track_info is not None}, playlist_folder_mode: {track_info.get('_playlist_folder_mode', False) if track_info else False}")
|
||||
|
||||
# Update task with successful download info
|
||||
with tasks_lock:
|
||||
|
|
|
|||
|
|
@ -77,32 +77,21 @@ def _normalize_for_finding(text: str) -> str:
|
|||
return ""
|
||||
text = unidecode(text).lower()
|
||||
text = re.sub(r'[._/]', ' ', text)
|
||||
# Strip ONLY balanced bracket pairs (tags like "[FLAC]", "(Remastered 2016)").
|
||||
# The old combined pattern r'[\[\(].*?[\]\)]' allowed MISMATCHED delimiters, so a
|
||||
# lone unbalanced '[' — slskd reports "[34 - You & Me (Flume Remix)" but saves the
|
||||
# file as "34 - You & Me (Flume Remix)" — matched from that '[' all the way to the
|
||||
# next ')', eating the entire title and collapsing the search target to "flac". The
|
||||
# file then scored 0.40 against the real on-disk name and was reported "not found"
|
||||
# despite sitting right there. Per-delimiter pairs can't over-consume; a stray
|
||||
# unbalanced bracket simply survives to the alphanumeric strip below.
|
||||
text = re.sub(r'\[[^\]]*\]', '', text)
|
||||
text = re.sub(r'\([^)]*\)', '', text)
|
||||
text = re.sub(r'[\[\(].*?[\]\)]', '', text)
|
||||
text = re.sub(r'[^a-z0-9\s-]', '', text)
|
||||
return ' '.join(text.split()).strip()
|
||||
|
||||
|
||||
def _extract_basename(api_filename: str) -> str:
|
||||
"""Cross-platform rightmost-separator split for a real remote PATH.
|
||||
|
||||
A YouTube/Tidal/Qobuz ``id||title`` encoded filename is handled by
|
||||
returning the title VERBATIM: the title is not a filesystem path, so a '/'
|
||||
in it (e.g. the Sawano track ``YouSeeBIGGIRL/T:T``) is part of the name and
|
||||
must NOT be split on (issue #835)."""
|
||||
"""Cross-platform rightmost-separator split, with YouTube /
|
||||
Tidal ``id||title`` encoded filenames pre-normalised — the id
|
||||
half is stripped so the title becomes the basename. Mirrors
|
||||
the strip-then-split order ``web_server`` used."""
|
||||
if not api_filename:
|
||||
return ""
|
||||
if '||' in api_filename:
|
||||
_id, title = api_filename.split('||', 1)
|
||||
return title
|
||||
api_filename = title
|
||||
last_slash = max(api_filename.rfind('/'), api_filename.rfind('\\'))
|
||||
return api_filename[last_slash + 1:] if last_slash != -1 else api_filename
|
||||
|
||||
|
|
@ -247,23 +236,14 @@ def find_completed_audio_file(
|
|||
``None`` when the file isn't found anywhere — callers should
|
||||
treat that as "not yet" (still mid-write) or "lost".
|
||||
"""
|
||||
# YouTube / Tidal / Qobuz encoded filenames carry the id ahead of ``||``.
|
||||
# The title half is NOT a filesystem path: a '/' in it (e.g. the Sawano
|
||||
# track ``YouSeeBIGGIRL/T:T``) is part of the title, so it must NOT be
|
||||
# basename-split or read as a remote directory component — doing so
|
||||
# truncated the search target to ``T:T`` and the real file was never found,
|
||||
# quarantining valid downloads (issue #835). Real remote paths (Soulseek)
|
||||
# still get basename + dir-component extraction.
|
||||
encoded_title = None
|
||||
# YouTube / Tidal encoded filenames carry the id ahead of ``||``.
|
||||
# Strip it up front so basename + dir-component extraction both
|
||||
# operate on the title half.
|
||||
if api_filename and '||' in api_filename:
|
||||
_id, encoded_title = api_filename.split('||', 1)
|
||||
if encoded_title is not None:
|
||||
target_basename = encoded_title
|
||||
api_dirs = []
|
||||
else:
|
||||
target_basename = _extract_basename(api_filename)
|
||||
api_dirs = _api_dir_parts(api_filename)
|
||||
_id, api_filename = api_filename.split('||', 1)
|
||||
target_basename = _extract_basename(api_filename)
|
||||
normalized_target = _normalize_for_finding(target_basename)
|
||||
api_dirs = _api_dir_parts(api_filename)
|
||||
|
||||
best_dl_path, dl_sim = _search_in_directory(
|
||||
download_dir, 'downloads', target_basename, normalized_target, api_dirs,
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
"""Match a file back to its download-history row when its path has drifted (#934).
|
||||
|
||||
``library_history.file_path`` is frozen at import time, but the file moves afterward
|
||||
(media-server import, library reorganize) and ``tracks.file_path`` — what the AcoustID
|
||||
scanner reads — no longer equals it. Matching on the exact path alone then fails twice:
|
||||
the verification status never reaches the history row (verified tracks read "unverified"),
|
||||
and a fresh ``acoustid_scan`` row gets inserted every run (thousands of duplicates).
|
||||
|
||||
This module picks the canonical history row by exact path first, then by FILENAME guarded
|
||||
by a title check — so a shared filename ("01 - Intro.flac") can never heal the wrong song.
|
||||
Pure (no DB) so the matching rules are unit-testable; the caller does the SQL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Iterable, Optional, Sequence, Tuple
|
||||
|
||||
|
||||
def _norm_title(value) -> str:
|
||||
"""Alphanumeric-only lowercase form, so "Song (Remaster)" vs "song remaster"
|
||||
style drift between the download tag and the media-server tag still agrees."""
|
||||
return ''.join(ch for ch in str(value or '').lower() if ch.isalnum())
|
||||
|
||||
|
||||
def like_filename_filter(basename: str) -> str:
|
||||
"""A ``LIKE ... ESCAPE '\\'`` pattern that coarsely matches rows whose path ends
|
||||
in ``basename``. Escapes the LIKE metacharacters (``%`` ``_`` ``\\``) — filenames
|
||||
routinely contain underscores. Callers MUST still confirm with an exact basename
|
||||
compare (``pick_history_row`` does), since ``'%name'`` also matches ``'xname'``."""
|
||||
esc = basename.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
|
||||
return '%' + esc
|
||||
|
||||
|
||||
def pick_history_row(candidates: Sequence[Tuple], *, current_paths: Iterable[str],
|
||||
basename: str, title: str) -> Optional[int]:
|
||||
"""Return the id of the history row to update for this file, or None.
|
||||
|
||||
``candidates``: ``(id, file_path, title, download_source)`` rows the DB pre-filtered
|
||||
(exact path or filename LIKE). A row matches when its path equals the current path OR
|
||||
its filename matches AND its title agrees — the title guard prevents a shared filename
|
||||
("01 - Intro.flac") from healing a different song's row. Among matches a REAL download
|
||||
row is preferred over a synthetic ``acoustid_scan`` row, so the scanner heals the
|
||||
genuine record and the caller can delete the synthetic duplicate. None when nothing
|
||||
matches safely (caller then inserts a fresh row — the "file SoulSync never downloaded"
|
||||
intent)."""
|
||||
paths = {p for p in current_paths if p}
|
||||
want = _norm_title(title)
|
||||
matches: list = [] # (id, is_exact, is_real)
|
||||
for cid, cpath, ctitle, csource in candidates:
|
||||
is_real = csource != 'acoustid_scan'
|
||||
if cpath and cpath in paths:
|
||||
matches.append((cid, True, is_real))
|
||||
elif (basename and cpath and os.path.basename(cpath) == basename
|
||||
and (not want or not _norm_title(ctitle) or _norm_title(ctitle) == want)):
|
||||
matches.append((cid, False, is_real))
|
||||
if not matches:
|
||||
return None
|
||||
# Prefer a REAL download row over a synthetic acoustid_scan row; within that, prefer an
|
||||
# exact-path match over a filename match. Stable, so ties keep DB order (first/oldest id).
|
||||
matches.sort(key=lambda m: (m[2], m[1]), reverse=True)
|
||||
return matches[0][0]
|
||||
|
|
@ -26,7 +26,6 @@ Lifted verbatim from web_server.py. Dependencies injected via
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import traceback
|
||||
|
|
@ -45,27 +44,6 @@ from core.runtime_state import (
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# A task that has been in 'post_processing' longer than this is treated as stuck.
|
||||
# Post-processing (AcoustID + quality + import) is serialized, so a large batch
|
||||
# legitimately backs up — keep this generous so genuinely-slow imports aren't
|
||||
# cut off mid-flight (the old 5-min cutoff falsely "completed" queued tasks).
|
||||
_POST_PROCESSING_STUCK_TIMEOUT = 1800 # 30 minutes
|
||||
|
||||
|
||||
def _resolve_stuck_post_processing_status(task: dict) -> str:
|
||||
"""Decide the terminal status for a task stuck in post_processing.
|
||||
|
||||
Only call it 'completed' if the import actually produced a file on disk
|
||||
(``final_file_path`` is set at the end of successful post-processing). Without
|
||||
a real file, force-completing is a lie — the task shows as a downloaded track
|
||||
that isn't anywhere. Mark those 'failed' so they're retryable and honest.
|
||||
"""
|
||||
final_path = task.get('final_file_path')
|
||||
if final_path and os.path.exists(final_path):
|
||||
return 'completed'
|
||||
return 'failed'
|
||||
|
||||
|
||||
def _safe_batch_dirname(batch_id: str) -> str:
|
||||
safe = ''.join(ch if ch.isalnum() or ch in ('-', '_') else '_' for ch in str(batch_id or 'batch'))
|
||||
return safe or 'batch'
|
||||
|
|
@ -457,15 +435,9 @@ def on_download_completed(batch_id: str, task_id: str, success: bool, deps: Life
|
|||
retrying_count += 1
|
||||
elif task_status == 'post_processing':
|
||||
task_age = current_time - task.get('status_change_time', current_time)
|
||||
if task_age > _POST_PROCESSING_STUCK_TIMEOUT:
|
||||
new_status = _resolve_stuck_post_processing_status(task)
|
||||
if new_status == 'completed':
|
||||
logger.info(f"⏰ [Stuck Detection] Task {queue_task_id} stuck in post_processing for {task_age:.0f}s but file exists — completing")
|
||||
task['status'] = 'completed'
|
||||
else:
|
||||
logger.warning(f"⏰ [Stuck Detection] Task {queue_task_id} stuck in post_processing for {task_age:.0f}s with no output file — marking failed")
|
||||
task['status'] = 'failed'
|
||||
task['error_message'] = 'Post-processing timed out without producing a file'
|
||||
if task_age > 300: # 5 minutes (post-processing should be fast)
|
||||
logger.info(f"⏰ [Stuck Detection] Task {queue_task_id} stuck in post_processing for {task_age:.0f}s - forcing completion")
|
||||
task['status'] = 'completed' # Assume it worked if file verification is taking too long
|
||||
finished_count += 1
|
||||
else:
|
||||
retrying_count += 1
|
||||
|
|
@ -577,24 +549,6 @@ def on_download_completed(batch_id: str, task_id: str, success: bool, deps: Life
|
|||
except Exception as m3u_err:
|
||||
logger.error(f"[M3U] Error regenerating M3U on batch complete: {m3u_err}")
|
||||
|
||||
# PLAYLIST MATERIALIZE: one path-independent reconcile — drop this
|
||||
# batch's newly-resolved tracks into the right Playlists/<name>/
|
||||
# folders. Covers an organize-by-playlist download AND a late
|
||||
# wishlist arrival (via each track's playlist provenance). Built
|
||||
# from the batch's own captured paths — non-fatal, derived view.
|
||||
try:
|
||||
from core.playlists.materialize_service import reconcile_batch_playlists
|
||||
from database.music_database import MusicDatabase
|
||||
for _pl_name, _mat in reconcile_batch_playlists(MusicDatabase(), batch, download_tasks, deps.config_manager):
|
||||
logger.info(
|
||||
f"[Playlist Folder] Rebuilt '{_mat.playlist_dir}': "
|
||||
f"{_mat.linked} linked, {_mat.copied} copied, "
|
||||
f"{_mat.unchanged} unchanged, {_mat.removed_stale} stale removed"
|
||||
+ (" (symlinks unsupported here → copied)" if _mat.fellback else "")
|
||||
)
|
||||
except Exception as _mat_err:
|
||||
logger.error(f"[Playlist Folder] Materialize failed (non-fatal): {_mat_err}")
|
||||
|
||||
# REPAIR: Scan all album folders from this batch for track number issues
|
||||
if deps.repair_worker:
|
||||
deps.repair_worker.process_batch(batch_id)
|
||||
|
|
@ -695,15 +649,9 @@ def check_batch_completion_v2(batch_id: str, deps: LifecycleDeps) -> Optional[bo
|
|||
retrying_count += 1
|
||||
elif task_status == 'post_processing':
|
||||
task_age = current_time - task.get('status_change_time', current_time)
|
||||
if task_age > _POST_PROCESSING_STUCK_TIMEOUT:
|
||||
new_status = _resolve_stuck_post_processing_status(task)
|
||||
if new_status == 'completed':
|
||||
logger.info(f"⏰ [Stuck Detection V2] Task {task_id} stuck in post_processing for {task_age:.0f}s but file exists — completing")
|
||||
task['status'] = 'completed'
|
||||
else:
|
||||
logger.warning(f"⏰ [Stuck Detection V2] Task {task_id} stuck in post_processing for {task_age:.0f}s with no output file — marking failed")
|
||||
task['status'] = 'failed'
|
||||
task['error_message'] = 'Post-processing timed out without producing a file'
|
||||
if task_age > 300: # 5 minutes (post-processing should be fast)
|
||||
logger.info(f"⏰ [Stuck Detection V2] Task {task_id} stuck in post_processing for {task_age:.0f}s - forcing completion")
|
||||
task['status'] = 'completed' # Assume it worked if file verification is taking too long
|
||||
finished_count += 1
|
||||
else:
|
||||
retrying_count += 1
|
||||
|
|
@ -787,23 +735,6 @@ def check_batch_completion_v2(batch_id: str, deps: LifecycleDeps) -> Optional[bo
|
|||
deps.download_monitor.stop_monitoring(batch_id)
|
||||
_cleanup_private_album_bundle_staging(batch_id, batch)
|
||||
|
||||
# PLAYLIST MATERIALIZE: same reconcile as the primary completion path
|
||||
# (on_download_completed). Monitor-detected downloads complete via THIS
|
||||
# V2 path, so the reconcile must run here too or playlist folders never
|
||||
# get built for them. Path-independent, non-fatal, derived view.
|
||||
try:
|
||||
from core.playlists.materialize_service import reconcile_batch_playlists
|
||||
from database.music_database import MusicDatabase
|
||||
for _pl_name, _mat in reconcile_batch_playlists(MusicDatabase(), batch, download_tasks, deps.config_manager):
|
||||
logger.info(
|
||||
f"[Playlist Folder] Rebuilt '{_mat.playlist_dir}': "
|
||||
f"{_mat.linked} linked, {_mat.copied} copied, "
|
||||
f"{_mat.unchanged} unchanged, {_mat.removed_stale} stale removed"
|
||||
+ (" (symlinks unsupported here → copied)" if _mat.fellback else "")
|
||||
)
|
||||
except Exception as _mat_err:
|
||||
logger.error(f"[Playlist Folder] Materialize failed (non-fatal): {_mat_err}")
|
||||
|
||||
# REPAIR: Scan all album folders from this batch for track number issues
|
||||
if deps.repair_worker:
|
||||
deps.repair_worker.process_batch(batch_id)
|
||||
|
|
|
|||
|
|
@ -315,52 +315,7 @@ class _BatchStateAccessImpl:
|
|||
row['album_bundle_state'] = 'failed'
|
||||
|
||||
|
||||
# Task states that mean a batch still has work in flight. While ANY of a batch's
|
||||
# tasks is in one of these, a serialized album-pool worker keeps its slot.
|
||||
_NON_TERMINAL_TASK_STATUSES = ('pending', 'queued', 'searching', 'downloading', 'post_processing')
|
||||
|
||||
|
||||
def _wait_for_batch_drain(batch_id: str, poll_seconds: float = 1.5,
|
||||
max_wait_seconds: float = 3600.0) -> None:
|
||||
"""Block until every task in ``batch_id`` reaches a terminal state (the batch
|
||||
is fully drained), the batch is removed, shutdown is requested, or a safety
|
||||
cap elapses.
|
||||
|
||||
Used to make the dedicated album-bundle pool actually SERIALIZE albums: the
|
||||
worker holds its pool slot for the album's whole lifetime instead of
|
||||
returning the instant downloads are started. That stops every album from
|
||||
dumping its tracks into the shared download pool at once (Sokhi: "searching
|
||||
for way too many tracks at once"). It's a PASSIVE wait — the downloads are
|
||||
driven by the monitor + completion callbacks on other threads, so this never
|
||||
drives the work and can't deadlock; worst case the cap releases the slot and
|
||||
the downloads simply finish in the background."""
|
||||
from core.downloads import monitor as _monitor
|
||||
start = time.time()
|
||||
while True:
|
||||
if getattr(_monitor, 'IS_SHUTTING_DOWN', False):
|
||||
return
|
||||
with tasks_lock:
|
||||
batch = download_batches.get(batch_id)
|
||||
if not batch:
|
||||
return
|
||||
queue = list(batch.get('queue', ()) or ())
|
||||
still_working = any(
|
||||
download_tasks.get(t, {}).get('status') in _NON_TERMINAL_TASK_STATUSES
|
||||
for t in queue
|
||||
)
|
||||
if not still_working:
|
||||
return
|
||||
if time.time() - start > max_wait_seconds:
|
||||
logger.warning(
|
||||
"[Album Serialize] batch %s not drained after %.0fs — releasing the "
|
||||
"album-pool slot (its downloads continue in the background)",
|
||||
batch_id, max_wait_seconds)
|
||||
return
|
||||
time.sleep(poll_seconds)
|
||||
|
||||
|
||||
def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: MasterDeps,
|
||||
serialize: bool = False):
|
||||
def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: MasterDeps):
|
||||
"""
|
||||
A master worker that handles the entire missing tracks process:
|
||||
1. Runs the analysis.
|
||||
|
|
@ -388,17 +343,6 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
batch_is_album = False
|
||||
batch_profile_id = 1
|
||||
batch_source = 'spotify'
|
||||
batch_playlist_folder_mode = False
|
||||
batch_playlist_name = 'Unknown Playlist'
|
||||
batch_playlist_id = playlist_id
|
||||
batch_source_playlist_ref = ''
|
||||
# Issue #797 — per-request "Skip AcoustID verification" toggle from
|
||||
# the album-download modal. When set, every track in this batch
|
||||
# bypasses the AcoustID quarantine gate (the user has chosen to
|
||||
# trust the metadata over fingerprint disagreement — useful for
|
||||
# non-English artists whose native-script metadata AcoustID can't
|
||||
# reconcile with the romanized request).
|
||||
batch_skip_acoustid = False
|
||||
with tasks_lock:
|
||||
if batch_id in download_batches:
|
||||
force_download_all = download_batches[batch_id].get('force_download_all', False)
|
||||
|
|
@ -408,31 +352,6 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
batch_artist_context = download_batches[batch_id].get('artist_context')
|
||||
batch_profile_id = download_batches[batch_id].get('profile_id', 1) or 1
|
||||
batch_source = download_batches[batch_id].get('batch_source', 'spotify') or 'spotify'
|
||||
batch_playlist_folder_mode = download_batches[batch_id].get('playlist_folder_mode', False)
|
||||
batch_playlist_name = download_batches[batch_id].get('playlist_name', 'Unknown Playlist')
|
||||
batch_playlist_id = download_batches[batch_id].get('playlist_id', playlist_id)
|
||||
batch_source_playlist_ref = (
|
||||
download_batches[batch_id].get('source_playlist_ref') or ''
|
||||
).strip()
|
||||
batch_skip_acoustid = bool(download_batches[batch_id].get('skip_acoustid', False))
|
||||
|
||||
from core.downloads.playlist_folder import (
|
||||
resolve_playlist_folder_mode_for_batch,
|
||||
track_exists_in_playlist_folder_from_track_data,
|
||||
)
|
||||
effective_playlist_folder_mode, effective_playlist_name = resolve_playlist_folder_mode_for_batch(
|
||||
db,
|
||||
playlist_id=str(batch_playlist_id),
|
||||
playlist_name=batch_playlist_name,
|
||||
batch_playlist_folder_mode=batch_playlist_folder_mode,
|
||||
profile_id=batch_profile_id,
|
||||
source=batch_source,
|
||||
)
|
||||
if effective_playlist_folder_mode and not batch_playlist_folder_mode:
|
||||
with tasks_lock:
|
||||
if batch_id in download_batches:
|
||||
download_batches[batch_id]['playlist_folder_mode'] = True
|
||||
download_batches[batch_id]['playlist_name'] = effective_playlist_name
|
||||
|
||||
if force_download_all:
|
||||
logger.warning(f"[Force Download] Force download mode enabled for batch {batch_id} - treating all tracks as missing")
|
||||
|
|
@ -505,19 +424,13 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
track_name = track_data.get('name', '')
|
||||
artists = track_data.get('artists', [])
|
||||
found, confidence = False, 0.0
|
||||
# Additive payload: the owned library track (DatabaseTrack) when this
|
||||
# item is found in the library, so downstream (playlist materialization)
|
||||
# knows WHERE the real file is without re-matching. None when not owned.
|
||||
matched_track = None
|
||||
|
||||
# Manual library matches are authoritative unless the user explicitly
|
||||
# requested a force re-download from the normal download modal.
|
||||
_stid = track_data.get('spotify_track_id') or track_data.get('source_track_id') or track_data.get('id', '')
|
||||
_manual_match = (
|
||||
_mlm.get_match_for_track(db, batch_profile_id, track_data, default_source=batch_source)
|
||||
if (not ignore_manual_matches and _stid) else None
|
||||
)
|
||||
if _manual_match:
|
||||
if not ignore_manual_matches and _stid and _mlm.get_match_for_track(
|
||||
db, batch_profile_id, track_data, default_source=batch_source
|
||||
):
|
||||
logger.info(f"[Manual Match] '{track_name}' already matched in library — skipping download")
|
||||
try:
|
||||
deps.check_and_remove_track_from_wishlist_by_metadata(track_data)
|
||||
|
|
@ -529,32 +442,9 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
'found': True,
|
||||
'confidence': 1.0,
|
||||
'match_reason': 'manual_library_match',
|
||||
'matched_file_path': _manual_match.get('library_file_path'),
|
||||
'matched_track_id': _manual_match.get('library_track_id'),
|
||||
})
|
||||
continue
|
||||
|
||||
if effective_playlist_folder_mode and not force_download_all:
|
||||
if track_exists_in_playlist_folder_from_track_data(
|
||||
effective_playlist_name,
|
||||
track_data,
|
||||
):
|
||||
logger.info(
|
||||
f"[Playlist Folder] '{track_name}' already on disk in playlist folder — skipping download"
|
||||
)
|
||||
try:
|
||||
deps.check_and_remove_track_from_wishlist_by_metadata(track_data)
|
||||
except Exception as _wl_err:
|
||||
logger.debug(f"[Playlist Folder] Wishlist removal attempt failed: {_wl_err}")
|
||||
analysis_results.append({
|
||||
'track_index': track_index,
|
||||
'track': track_data,
|
||||
'found': True,
|
||||
'confidence': 1.0,
|
||||
'match_reason': 'playlist_folder_file',
|
||||
})
|
||||
continue
|
||||
|
||||
# Skip database check if force download is enabled
|
||||
if force_download_all:
|
||||
logger.warning(f"[Force Download] Skipping database check for '{track_name}' - treating as missing")
|
||||
|
|
@ -576,10 +466,8 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
# Direct title match (try both raw and normalized)
|
||||
if track_name_lower in album_tracks_map:
|
||||
found, confidence = True, 1.0
|
||||
matched_track = album_tracks_map[track_name_lower]
|
||||
elif _normalized_source_title and _normalized_source_title in album_tracks_map:
|
||||
found, confidence = True, 1.0
|
||||
matched_track = album_tracks_map[_normalized_source_title]
|
||||
else:
|
||||
# Fuzzy match against album tracks using string similarity.
|
||||
# Compare BOTH the raw and normalized source titles —
|
||||
|
|
@ -587,17 +475,14 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
# matching when the album doesn't imply version
|
||||
# context (helper returns the input unchanged).
|
||||
best_sim = 0.0
|
||||
best_track = None
|
||||
for db_title_lower, _db_track in album_tracks_map.items():
|
||||
sim_raw = db._string_similarity(track_name_lower, db_title_lower)
|
||||
sim_norm = db._string_similarity(_normalized_source_title, db_title_lower) if _normalized_source_title else 0.0
|
||||
sim = max(sim_raw, sim_norm)
|
||||
if sim > best_sim:
|
||||
best_sim = sim
|
||||
best_track = _db_track
|
||||
if best_sim >= 0.7:
|
||||
found, confidence = True, best_sim
|
||||
matched_track = best_track
|
||||
else:
|
||||
# Fall back to global per-track search for this track
|
||||
# When allow_duplicates is on for album downloads, skip global
|
||||
|
|
@ -618,7 +503,6 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
)
|
||||
if db_track and track_confidence >= 0.7:
|
||||
found, confidence = True, track_confidence
|
||||
matched_track = db_track
|
||||
break
|
||||
elif allow_duplicates and batch_is_album:
|
||||
# Allow duplicates + album download + album not in DB yet → treat all as missing
|
||||
|
|
@ -638,15 +522,10 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
)
|
||||
if db_track and track_confidence >= 0.7:
|
||||
found, confidence = True, track_confidence
|
||||
matched_track = db_track
|
||||
break
|
||||
|
||||
analysis_results.append({
|
||||
'track_index': track_index, 'track': track_data, 'found': found, 'confidence': confidence,
|
||||
# Additive: real on-disk location of the owned track (None when not
|
||||
# owned), so playlist materialization links the right file.
|
||||
'matched_file_path': getattr(matched_track, 'file_path', None),
|
||||
'matched_track_id': getattr(matched_track, 'id', None),
|
||||
'track_index': track_index, 'track': track_data, 'found': found, 'confidence': confidence
|
||||
})
|
||||
|
||||
# WISHLIST REMOVAL: If track is found in database, check if it should be removed from wishlist
|
||||
|
|
@ -672,34 +551,6 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
if skipped > 0:
|
||||
logger.warning(f"[Content Filter] Filtered out {skipped} explicit track(s) from download queue")
|
||||
|
||||
# Blocklist (Phase 2a): drop banned artists/albums/tracks before queueing,
|
||||
# so a blocked item can't slip in via playlist sync / album download /
|
||||
# discography. Same ID-cascade brain as the wishlist guard (Phase 1) —
|
||||
# the only other auto-acquisition path. Skipped when the user confirmed
|
||||
# "download anyway" at the modal (Phase 2b override).
|
||||
_ignore_blocklist = False
|
||||
with tasks_lock:
|
||||
if batch_id in download_batches:
|
||||
_ignore_blocklist = download_batches[batch_id].get('ignore_blocklist', False)
|
||||
if not _ignore_blocklist:
|
||||
try:
|
||||
_bl_before = len(missing_tracks)
|
||||
_bl_kept = []
|
||||
for res in missing_tracks:
|
||||
reason = db.blocklist_reason_for_track(
|
||||
batch_profile_id, res.get('track', {}), source=batch_source)
|
||||
if reason:
|
||||
logger.info("[Blocklist] Skipping %s '%s' from download queue (%s blocked)",
|
||||
reason[0], res.get('track', {}).get('name', '?'), reason[0])
|
||||
else:
|
||||
_bl_kept.append(res)
|
||||
if len(_bl_kept) != _bl_before:
|
||||
logger.info("[Blocklist] Filtered out %d blocklisted track(s) from download queue",
|
||||
_bl_before - len(_bl_kept))
|
||||
missing_tracks = _bl_kept
|
||||
except Exception as _bl_err:
|
||||
logger.debug("blocklist queue filter skipped: %s", _bl_err)
|
||||
|
||||
with tasks_lock:
|
||||
if batch_id in download_batches:
|
||||
download_batches[batch_id]['analysis_results'] = analysis_results
|
||||
|
|
@ -785,41 +636,6 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
logger.warning("[Auto-Wishlist] No missing tracks found - calling auto-completion handler to toggle cycle and reschedule")
|
||||
deps.missing_download_executor.submit(deps.process_failed_tracks_to_wishlist_exact_with_auto_completion, batch_id)
|
||||
|
||||
# Organize-by-playlist with NOTHING to download (every track already
|
||||
# owned): the batch never enters the download/lifecycle path, so build
|
||||
# the playlist folder here from the owned files the analysis matched.
|
||||
# Gated + non-fatal; runs once after analysis, not in the per-track loop.
|
||||
if effective_playlist_folder_mode:
|
||||
try:
|
||||
from core.playlists.materialize_service import reconcile_batch_playlists
|
||||
from database.music_database import MusicDatabase as _MDB
|
||||
_batch = download_batches.get(batch_id)
|
||||
if _batch is not None:
|
||||
# We KNOW the intent is organize-by-playlist here (the gate
|
||||
# above). The line-431 sync only writes the dict field when
|
||||
# effective and NOT batch_playlist_folder_mode, so when the
|
||||
# toggle itself drove it the dict field can still be falsy —
|
||||
# which makes reconcile build no batch ref. Make the dict
|
||||
# authoritative so reconcile sees the batch's own playlist.
|
||||
_batch['playlist_folder_mode'] = True
|
||||
if effective_playlist_name:
|
||||
_batch['playlist_name'] = effective_playlist_name
|
||||
_results = reconcile_batch_playlists(_MDB(), _batch, download_tasks, deps.config_manager)
|
||||
if not _results:
|
||||
logger.info(
|
||||
f"[Playlist Folder] All-owned: nothing rebuilt for "
|
||||
f"ref={_batch.get('source_playlist_ref') or _batch.get('playlist_id')} "
|
||||
f"source={_batch.get('batch_source')}"
|
||||
)
|
||||
for _pl_name, _mat in _results:
|
||||
logger.info(
|
||||
f"[Playlist Folder] Rebuilt '{_mat.playlist_dir}' (all owned): "
|
||||
f"{_mat.linked} linked, {_mat.copied} copied, {_mat.removed_stale} stale removed"
|
||||
+ (" (symlinks unsupported here → copied)" if _mat.fellback else "")
|
||||
)
|
||||
except Exception as _mat_err:
|
||||
logger.error(f"[Playlist Folder] All-owned materialize failed (non-fatal): {_mat_err}")
|
||||
|
||||
return
|
||||
|
||||
logger.warning(f" transitioning batch {batch_id} to download phase with {len(missing_tracks)} tracks.")
|
||||
|
|
@ -1166,81 +982,13 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
logger.info(f"[Wishlist] Added album context for: '{track_info.get('name')}' -> '{album_ctx['name']}'")
|
||||
|
||||
|
||||
# Issue #797 — propagate the batch-level "skip AcoustID"
|
||||
# toggle onto each track so the per-track download context
|
||||
# (built in core/downloads/candidates.py) can set the
|
||||
# AcoustID quarantine bypass. Mirrors the _playlist_folder_mode
|
||||
# threading pattern below.
|
||||
if batch_skip_acoustid:
|
||||
track_info['_skip_acoustid'] = True
|
||||
|
||||
# Add playlist folder mode flag for sync page playlists and wishlist
|
||||
# tracks tied to a mirrored playlist with organize_by_playlist enabled.
|
||||
task_pl_folder_mode = batch_playlist_folder_mode
|
||||
task_pl_name = batch_playlist_name
|
||||
if not task_pl_folder_mode and playlist_id == 'wishlist':
|
||||
wl_source = track_info.get('source_info') or {}
|
||||
if isinstance(wl_source, str):
|
||||
try:
|
||||
wl_source = json.loads(wl_source)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
wl_source = {}
|
||||
wl_pl_ref = wl_source.get('playlist_id')
|
||||
wl_pl_name = wl_source.get('playlist_name')
|
||||
wl_pl_source = wl_source.get('source') or 'spotify'
|
||||
if wl_pl_ref and hasattr(db, 'resolve_mirrored_playlist'):
|
||||
wl_mirrored = db.resolve_mirrored_playlist(
|
||||
wl_pl_ref,
|
||||
profile_id=batch_profile_id,
|
||||
default_source=wl_pl_source,
|
||||
)
|
||||
if wl_mirrored and wl_mirrored.get('organize_by_playlist'):
|
||||
task_pl_folder_mode = True
|
||||
task_pl_name = wl_pl_name or wl_mirrored.get('name') or batch_playlist_name
|
||||
if task_pl_folder_mode:
|
||||
# Organize-by-playlist now imports each track NORMALLY into the
|
||||
# Artist/Album library (i.e. exactly what a normal download does)
|
||||
# — the playlist folder is built as links/copies AFTER the batch
|
||||
# from the real library files. So we deliberately DON'T set
|
||||
# `_playlist_folder_mode` (which routed the real file into a flat
|
||||
# Music/<playlist>/ dump). We keep `_playlist_name` + source_info
|
||||
# — they're download provenance (core/downloads/origin.py).
|
||||
track_info['_playlist_name'] = task_pl_name
|
||||
if batch_source_playlist_ref:
|
||||
track_info['source_info'] = {
|
||||
'playlist_id': batch_source_playlist_ref,
|
||||
'playlist_name': task_pl_name,
|
||||
'source': batch_source,
|
||||
}
|
||||
logger.info(
|
||||
f"[Task Creation] Organize-by-playlist (normal import + "
|
||||
f"materialize after batch): {track_info.get('name')} → {task_pl_name}"
|
||||
)
|
||||
# Add playlist folder mode flag for sync page playlists
|
||||
if batch_playlist_folder_mode:
|
||||
track_info['_playlist_folder_mode'] = True
|
||||
track_info['_playlist_name'] = batch_playlist_name
|
||||
logger.info(f"[Task Creation] Added playlist folder mode for: {track_info.get('name')} → {batch_playlist_name}")
|
||||
else:
|
||||
logger.debug(
|
||||
f"[Debug] Task Creation - playlist folder mode NOT enabled for: "
|
||||
f"{track_info.get('name')}"
|
||||
)
|
||||
|
||||
# Download-origin provenance: stamp what TRIGGERED this download
|
||||
# so the history chokepoint can record it (origin-history modal).
|
||||
# Wishlist rows already ride their source_info in track_info
|
||||
# (watchlist_artist_name / playlist_name — the deriver reads
|
||||
# those directly); this stamp covers DIRECT playlist batches,
|
||||
# where the playlist context otherwise only survives in
|
||||
# folder mode.
|
||||
if '_dl_origin' not in track_info and batch_source_playlist_ref and batch_playlist_name:
|
||||
_prov_si = track_info.get('source_info') or {}
|
||||
if isinstance(_prov_si, str):
|
||||
try:
|
||||
_prov_si = json.loads(_prov_si)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
_prov_si = {}
|
||||
if not _prov_si.get('watchlist_artist_name'):
|
||||
track_info['_dl_origin'] = 'playlist'
|
||||
track_info['_dl_origin_context'] = (
|
||||
_prov_si.get('playlist_name') or batch_playlist_name
|
||||
)
|
||||
logger.debug(f"[Debug] Task Creation - playlist folder mode NOT enabled for: {track_info.get('name')}")
|
||||
|
||||
download_tasks[task_id] = {
|
||||
'status': 'pending', 'track_info': track_info,
|
||||
|
|
@ -1255,16 +1003,6 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
deps.download_monitor.start_monitoring(batch_id)
|
||||
deps.start_next_batch_of_downloads(batch_id)
|
||||
|
||||
# Album-bundle batches run on the dedicated album pool and pass
|
||||
# serialize=True: hold this pool slot until the album finishes so only a
|
||||
# few albums are ever in flight at once, instead of every album batch
|
||||
# immediately starting and flooding the shared download pool with
|
||||
# 'searching' tracks (#740 / Sokhi). The residual + playlist + manual
|
||||
# paths run on the shared download pool and DON'T serialize (blocking
|
||||
# there would steal an actual download worker).
|
||||
if serialize:
|
||||
_wait_for_batch_drain(batch_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Master worker for batch {batch_id} failed: {e}")
|
||||
import traceback
|
||||
|
|
|
|||
|
|
@ -37,233 +37,11 @@ missing_download_executor = None
|
|||
download_orchestrator = None
|
||||
_RELEASE_SOURCE_NAMES = frozenset(('torrent', 'usenet'))
|
||||
|
||||
# Hard ceiling on automatic next-candidate retries after a download was
|
||||
# quarantined (AcoustID mismatch / integrity / duration). The natural
|
||||
# terminator is used_sources exhaustion — once every candidate the worker can
|
||||
# find has been tried, attempt_download_with_candidates returns False and the
|
||||
# worker reports a clean failure. This cap is a safety net against a pathological
|
||||
# quarantine→retry→quarantine loop (e.g. a source that keeps returning fresh
|
||||
# wrong files).
|
||||
#
|
||||
# Default (non-exhaustive) mode uses this single global cap. The opt-in
|
||||
# exhaustive mode (post_processing.retry_exhaustive) instead budgets retries
|
||||
# PER SOURCE — see requeue_quarantined_task_for_retry.
|
||||
MAX_QUARANTINE_RETRIES = 5
|
||||
|
||||
# Absolute runaway guard for exhaustive mode. Per-source budgets are already
|
||||
# finite (query_count × retries_per_query, and Soulseek peers all collapse to
|
||||
# one 'soulseek' bucket), but this ceiling caps the TOTAL retries across every
|
||||
# source so a misbehaving source-resolution can never loop forever.
|
||||
MAX_TOTAL_QUARANTINE_RETRIES = 100
|
||||
|
||||
# Streaming plugins report their source name as the download's "username"
|
||||
# (see download_orchestrator._streaming_sources). Soulseek uses the peer name
|
||||
# instead, so anything not in this set is bucketed under 'soulseek' for the
|
||||
# per-source retry budget.
|
||||
_STREAMING_SOURCE_NAMES = frozenset((
|
||||
'youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon',
|
||||
))
|
||||
|
||||
|
||||
def _resolve_download_source(username):
|
||||
"""Map a download's username to its logical source for per-source budgeting.
|
||||
|
||||
Streaming sources use the source name as username; Soulseek uses the peer
|
||||
name, so every Soulseek peer collapses to a single 'soulseek' bucket.
|
||||
"""
|
||||
if username and username in _STREAMING_SOURCE_NAMES:
|
||||
return username
|
||||
return 'soulseek'
|
||||
|
||||
|
||||
def _remaining_fallback_sources(exhausted):
|
||||
"""Sources in the configured hybrid chain that haven't exhausted their
|
||||
per-source budget yet.
|
||||
|
||||
When a source spends its whole budget (exhaustive mode), the task switches
|
||||
to the next source instead of failing — but only if there *is* another
|
||||
source. Single-source mode has nothing to fall back to, so this returns
|
||||
empty there (and when the orchestrator isn't wired). The returned list
|
||||
drives both the give-up decision here and the worker's search-exclusion on
|
||||
the next attempt (see task_worker: exhausted_download_sources).
|
||||
"""
|
||||
orch = download_orchestrator
|
||||
if orch is None or getattr(orch, 'mode', None) != 'hybrid':
|
||||
return []
|
||||
chain = getattr(orch, 'hybrid_order', None) or []
|
||||
blocked = {str(s).lower() for s in exhausted}
|
||||
return [s for s in chain if str(s).lower() not in blocked]
|
||||
|
||||
|
||||
def _download_id_key(download_id):
|
||||
return f"download_id::{download_id}" if download_id else None
|
||||
|
||||
|
||||
def requeue_quarantined_task_for_retry(task_id, batch_id, trigger):
|
||||
"""Re-queue a task whose download was just quarantined so the worker tries
|
||||
the NEXT best candidate instead of failing outright.
|
||||
|
||||
Called from the post-processing verification wrapper when AcoustID
|
||||
verification or the integrity/duration check quarantines a file. It mirrors
|
||||
the monitor's transfer-error retry path: mark the bad source as used, clear
|
||||
the stale download identity, reset the task to ``searching`` and resubmit
|
||||
the download worker. Because ``used_sources`` is preserved across the
|
||||
re-run, the worker skips the quarantined source and picks the next-best
|
||||
candidate (see ``attempt_download_with_candidates``).
|
||||
|
||||
Returns True if a retry was queued — the caller must then NOT mark the task
|
||||
failed or notify batch completion, since the task is going around again.
|
||||
Returns False when no retry is possible (retry engine unwired, manual pick,
|
||||
cancelled, or retry budget exhausted); the caller falls through to its
|
||||
existing failure handling.
|
||||
"""
|
||||
# Opt-out escape hatch — default on. Lets users restore the old
|
||||
# quarantine-and-fail behaviour without a code change.
|
||||
if not config_manager.get('post_processing.retry_next_candidate_on_mismatch', True):
|
||||
return False
|
||||
|
||||
# Retry engine not wired (e.g. manual-import path that never started a
|
||||
# download worker). Nothing to re-run.
|
||||
if missing_download_executor is None or _download_track_worker is None:
|
||||
return False
|
||||
|
||||
with tasks_lock:
|
||||
task = download_tasks.get(task_id)
|
||||
if not task:
|
||||
return False
|
||||
# The user explicitly picked this candidate via the candidates modal —
|
||||
# honour their choice rather than silently swapping in another file.
|
||||
# (Matches the monitor's transfer-retry guards.)
|
||||
if task.get('_user_manual_pick'):
|
||||
return False
|
||||
if task.get('status') == 'cancelled':
|
||||
return False
|
||||
|
||||
username = task.get('username')
|
||||
filename = task.get('filename')
|
||||
# No source identity means this wasn't a worker-dispatched download we
|
||||
# can retry — without the "{username}_{filename}" key we can't flag the
|
||||
# bad source as used, so a re-run could re-pick the same file and loop.
|
||||
# Bail and let the caller fail it normally.
|
||||
if not username or not filename:
|
||||
return False
|
||||
|
||||
total_count = task.get('quarantine_retry_count', 0)
|
||||
|
||||
if config_manager.get('post_processing.retry_exhaustive', False):
|
||||
# Exhaustive mode: a SEPARATE budget per source. The budget scales
|
||||
# with the track's own query count (the worker generates a variable
|
||||
# number of search queries per track) × the configured retries per
|
||||
# query. Soulseek candidates are walked first (one per retry), then
|
||||
# the worker's hybrid fallback moves to the next source — each source
|
||||
# spending its own budget. The natural terminator (used_sources
|
||||
# exhaustion → worker clean-fail) still ends most tracks well before
|
||||
# any budget is reached; the budget is the per-source safety ceiling.
|
||||
source = _resolve_download_source(username)
|
||||
retries_per_query = config_manager.get('post_processing.retries_per_query', 5)
|
||||
try:
|
||||
retries_per_query = int(retries_per_query)
|
||||
except (TypeError, ValueError):
|
||||
retries_per_query = 5
|
||||
if retries_per_query < 1:
|
||||
retries_per_query = 1
|
||||
|
||||
query_count = task.get('query_count') or 1
|
||||
if query_count < 1:
|
||||
query_count = 1
|
||||
budget = query_count * retries_per_query
|
||||
|
||||
counts = task.get('quarantine_retry_counts_by_source')
|
||||
if not isinstance(counts, dict):
|
||||
counts = {}
|
||||
source_count = counts.get(source, 0)
|
||||
|
||||
if source_count >= budget:
|
||||
# This source spent its whole budget. Rather than fail the
|
||||
# track outright, mark the source exhausted and fall through to
|
||||
# the next source in the hybrid chain (the worker excludes
|
||||
# exhausted sources from its next search). Only give up once no
|
||||
# fallback source remains — or the absolute ceiling trips.
|
||||
exhausted = set(task.get('exhausted_download_sources') or ())
|
||||
exhausted.add(source)
|
||||
remaining = _remaining_fallback_sources(exhausted)
|
||||
if not remaining:
|
||||
logger.warning(
|
||||
f"[Retry:{trigger}] Task {task_id} exhausted its retry "
|
||||
f"budget for source '{source}' ({source_count}/{budget}) "
|
||||
f"and no fallback source remains — giving up, marking failed"
|
||||
)
|
||||
return False
|
||||
if total_count >= MAX_TOTAL_QUARANTINE_RETRIES:
|
||||
logger.warning(
|
||||
f"[Retry:{trigger}] Task {task_id} hit the absolute retry "
|
||||
f"ceiling ({MAX_TOTAL_QUARANTINE_RETRIES}) — giving up, "
|
||||
f"marking failed"
|
||||
)
|
||||
return False
|
||||
task['exhausted_download_sources'] = exhausted
|
||||
# Don't push this source's counter past its budget — it's done.
|
||||
# The next source starts spending its own fresh budget when its
|
||||
# first candidate fails verification.
|
||||
attempt_desc = (
|
||||
f"source '{source}' budget spent ({source_count}/{budget}) "
|
||||
f"— switching sources (remaining: {', '.join(remaining)})"
|
||||
)
|
||||
else:
|
||||
if total_count >= MAX_TOTAL_QUARANTINE_RETRIES:
|
||||
logger.warning(
|
||||
f"[Retry:{trigger}] Task {task_id} hit the absolute retry "
|
||||
f"ceiling ({MAX_TOTAL_QUARANTINE_RETRIES}) — giving up, "
|
||||
f"marking failed"
|
||||
)
|
||||
return False
|
||||
counts[source] = source_count + 1
|
||||
task['quarantine_retry_counts_by_source'] = counts
|
||||
attempt_desc = f"source '{source}' {source_count + 1}/{budget}"
|
||||
else:
|
||||
# Default mode: a single global cap, conservative and predictable.
|
||||
if total_count >= MAX_QUARANTINE_RETRIES:
|
||||
logger.warning(
|
||||
f"[Retry:{trigger}] Task {task_id} hit the quarantine-retry cap "
|
||||
f"({MAX_QUARANTINE_RETRIES}) — giving up, marking failed"
|
||||
)
|
||||
return False
|
||||
attempt_desc = f"{total_count + 1}/{MAX_QUARANTINE_RETRIES}"
|
||||
|
||||
# Mark the quarantined source as used so the re-run won't pick it again.
|
||||
# Uses the same "{username}_{filename}" key the worker dedups against.
|
||||
used_sources = task.get('used_sources', set())
|
||||
used_sources.add(f"{username}_{filename}")
|
||||
task['used_sources'] = used_sources
|
||||
|
||||
task['quarantine_retry_count'] = total_count + 1
|
||||
# Flag the re-run as a quarantine retry so the worker walks the
|
||||
# already-found candidates (cached-first) before re-searching — the
|
||||
# connection was fine, the content was just wrong. Dead-connection /
|
||||
# stuck retries (handled elsewhere in the monitor) deliberately do NOT
|
||||
# set this, so they re-search fresh.
|
||||
task['_quarantine_retry'] = True
|
||||
# Drop the stale download identity + the prior attempt's quarantine link.
|
||||
task.pop('download_id', None)
|
||||
task.pop('username', None)
|
||||
task.pop('filename', None)
|
||||
task.pop('quarantine_entry_id', None)
|
||||
task['status'] = 'searching'
|
||||
task['status_change_time'] = time.time()
|
||||
# Surface the retry progress to the UI ("attempt 2/5" next to the
|
||||
# status while the task goes around again). Cleared implicitly on
|
||||
# completion (UI only renders it for active/queued states).
|
||||
task['retry_info'] = attempt_desc
|
||||
task['retry_trigger'] = trigger
|
||||
|
||||
logger.info(
|
||||
f"[Retry:{trigger}] Re-queuing task {task_id} for next-best candidate "
|
||||
f"(attempt {attempt_desc})"
|
||||
)
|
||||
missing_download_executor.submit(_download_track_worker, task_id, batch_id)
|
||||
return True
|
||||
|
||||
|
||||
def _is_release_task(task):
|
||||
ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
|
||||
username = task.get('username') or ti.get('username')
|
||||
|
|
@ -760,8 +538,7 @@ class WebUIDownloadMonitor:
|
|||
# used_sources keys are formatted as "{username}_{filename}", so startswith is exact.
|
||||
is_tidal = any(s.startswith('tidal_') for s in tried_sources)
|
||||
if is_tidal:
|
||||
from core.quality.source_map import quality_tier_for_source
|
||||
tidal_quality = quality_tier_for_source('tidal', default='lossless')
|
||||
tidal_quality = config_manager.get('tidal_download.quality', 'lossless')
|
||||
allow_fb = config_manager.get('tidal_download.allow_fallback', True)
|
||||
if tidal_quality == 'hires' and not allow_fb:
|
||||
task['error_message'] = (
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
"""Download-origin provenance: what TRIGGERED a download.
|
||||
|
||||
The library history records which SERVICE a file came from (Soulseek,
|
||||
YouTube, ...) but not WHY it was downloaded — a watchlist scan, a playlist
|
||||
sync, or a manual click. The origin-history modal (watchlist page / sync
|
||||
page) answers that, so the trigger must be derived once, at the history
|
||||
chokepoint (``record_library_history_download``), from the post-process
|
||||
context.
|
||||
|
||||
Signals, in priority order:
|
||||
1. explicit ``track_info._dl_origin`` / ``_dl_origin_context`` stamps
|
||||
(set at batch-task creation in core/downloads/master.py)
|
||||
2. wishlist provenance riding in ``track_info.source_info`` — watchlist
|
||||
items carry ``watchlist_artist_name``, playlist items ``playlist_name``
|
||||
3. the playlist-folder-mode ``_playlist_name`` thread
|
||||
|
||||
Anything unmatched derives ``(None, '')`` — manual/other downloads are
|
||||
intentionally not classified.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
ORIGIN_WATCHLIST = "watchlist"
|
||||
ORIGIN_PLAYLIST = "playlist"
|
||||
VALID_ORIGINS = (ORIGIN_WATCHLIST, ORIGIN_PLAYLIST)
|
||||
|
||||
|
||||
def _parse_source_info(raw: Any) -> Dict[str, Any]:
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if isinstance(raw, str) and raw:
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def derive_download_origin(context: Dict[str, Any]) -> Tuple[Optional[str], str]:
|
||||
"""Return ``(origin, origin_context)`` for a completed download.
|
||||
|
||||
``origin`` is 'watchlist' / 'playlist' / None; ``origin_context`` is the
|
||||
human label (watchlist artist name / playlist name). Never raises."""
|
||||
try:
|
||||
ti = context.get("track_info") or {}
|
||||
if not isinstance(ti, dict):
|
||||
return None, ""
|
||||
si = _parse_source_info(ti.get("source_info"))
|
||||
|
||||
# 1. Explicit stamp wins.
|
||||
origin = ti.get("_dl_origin")
|
||||
if origin in VALID_ORIGINS:
|
||||
return origin, str(ti.get("_dl_origin_context") or "")
|
||||
|
||||
# 2. Wishlist provenance riding in source_info.
|
||||
if si.get("watchlist_artist_name"):
|
||||
return ORIGIN_WATCHLIST, str(si["watchlist_artist_name"])
|
||||
if si.get("playlist_name"):
|
||||
return ORIGIN_PLAYLIST, str(si["playlist_name"])
|
||||
|
||||
# 3. Playlist-folder-mode thread.
|
||||
if ti.get("_playlist_name"):
|
||||
return ORIGIN_PLAYLIST, str(ti["_playlist_name"])
|
||||
|
||||
return None, ""
|
||||
except Exception:
|
||||
return None, ""
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
"""Identify dead review-queue history rows whose file is gone (#934 follow-up).
|
||||
|
||||
The Unverified/Quarantine review queue is fed from ``library_history`` — an
|
||||
append-only log that is never pruned. When a file is deleted, replaced, or
|
||||
re-downloaded elsewhere, its old ``unverified`` row lingers forever and can
|
||||
never be healed (there's no file left to confirm). Those are *orphans*.
|
||||
|
||||
This decides which rows are orphans, given a ``resolve(row) -> path | None``
|
||||
the caller wires to the real filesystem lookup. Pure (no DB, no filesystem) so
|
||||
the rules — including the safety gate — are unit-testable.
|
||||
|
||||
Safety gate: a filesystem check mass-false-positives when the library mount is
|
||||
down (every file looks missing). So if EVERY reviewed file is unreachable and
|
||||
there are enough rows to judge, we flag it ``suspicious`` and the caller refuses
|
||||
to delete — better to clean nothing than to wipe a healthy log during an outage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Sequence
|
||||
|
||||
|
||||
def find_orphan_history_ids(
|
||||
rows: Sequence[dict],
|
||||
resolve: Callable[[dict], Any],
|
||||
*,
|
||||
min_for_safety: int = 5,
|
||||
deletable: Callable[[dict], bool] | None = None,
|
||||
) -> dict:
|
||||
"""Return ``{'orphan_ids', 'checked', 'suspicious'}``.
|
||||
|
||||
A row is an orphan when it has a non-empty ``file_path`` but ``resolve`` can
|
||||
find no file for it. ``suspicious`` is True when every checked row is
|
||||
missing and there are at least ``min_for_safety`` of them — the mount-down
|
||||
signature; the caller should refuse to delete in that case.
|
||||
|
||||
``deletable`` (optional) protects rows from removal WITHOUT weakening the
|
||||
safety gate: a protected row still counts toward ``checked`` and the
|
||||
all-missing signal (so e.g. a few unverified orphans can't be swept during a
|
||||
mount outage just because protected rows were filtered out first), but it
|
||||
never appears in ``orphan_ids``. Default: every missing row is deletable.
|
||||
"""
|
||||
orphan_ids = []
|
||||
checked = 0
|
||||
missing = 0
|
||||
for row in rows:
|
||||
if not str((row.get('file_path') or '')).strip():
|
||||
continue
|
||||
checked += 1
|
||||
if resolve(row) is None:
|
||||
missing += 1
|
||||
if deletable is None or deletable(row):
|
||||
orphan_ids.append(row.get('id'))
|
||||
suspicious = checked >= min_for_safety and missing == checked
|
||||
return {'orphan_ids': orphan_ids, 'checked': checked, 'suspicious': suspicious}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
"""Playlist-folder layout helpers for download analysis and existence checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.downloads.file_finder import AUDIO_EXTENSIONS
|
||||
from core.imports.paths import (
|
||||
_get_config_manager,
|
||||
docker_resolve_path,
|
||||
get_file_path_from_template,
|
||||
sanitize_filename,
|
||||
)
|
||||
|
||||
|
||||
def _first_artist_name(artists: Any) -> str:
|
||||
if not artists:
|
||||
return ''
|
||||
first = artists[0]
|
||||
if isinstance(first, dict):
|
||||
return str(first.get('name', '') or '').strip()
|
||||
return str(first).strip()
|
||||
|
||||
|
||||
def candidate_playlist_folder_paths(
|
||||
playlist_name: str,
|
||||
artist: str,
|
||||
title: str,
|
||||
) -> List[str]:
|
||||
"""Return absolute candidate paths for a track in playlist-folder layout."""
|
||||
if not playlist_name or not title:
|
||||
return []
|
||||
|
||||
artist_name = (artist or 'Unknown Artist').strip()
|
||||
track_name = title.strip()
|
||||
transfer_dir = docker_resolve_path(
|
||||
_get_config_manager().get('soulseek.transfer_path', './Transfer')
|
||||
)
|
||||
|
||||
template_context = {
|
||||
'artist': artist_name,
|
||||
'albumartist': artist_name,
|
||||
'album': track_name,
|
||||
'title': track_name,
|
||||
'playlist_name': playlist_name,
|
||||
'track_number': 1,
|
||||
'disc_number': 1,
|
||||
'year': '',
|
||||
'quality': '',
|
||||
'albumtype': '',
|
||||
'_artists_list': [{'name': artist_name}],
|
||||
}
|
||||
|
||||
candidates: List[str] = []
|
||||
folder_path, filename_base = get_file_path_from_template(template_context, 'playlist_path')
|
||||
if folder_path and filename_base:
|
||||
base = os.path.join(transfer_dir, folder_path, filename_base)
|
||||
for ext in AUDIO_EXTENSIONS:
|
||||
candidates.append(base + ext)
|
||||
else:
|
||||
playlist_name_sanitized = sanitize_filename(playlist_name)
|
||||
playlist_dir = os.path.join(transfer_dir, playlist_name_sanitized)
|
||||
artist_name_sanitized = sanitize_filename(artist_name)
|
||||
track_name_sanitized = sanitize_filename(track_name)
|
||||
stem = f'{artist_name_sanitized} - {track_name_sanitized}'
|
||||
for ext in AUDIO_EXTENSIONS:
|
||||
candidates.append(os.path.join(playlist_dir, stem + ext))
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def track_exists_in_playlist_folder(
|
||||
playlist_name: str,
|
||||
artist: str,
|
||||
title: str,
|
||||
) -> bool:
|
||||
"""Return True if any audio file exists at the playlist-folder path for this track."""
|
||||
for path in candidate_playlist_folder_paths(playlist_name, artist, title):
|
||||
if os.path.isfile(path):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def track_exists_in_playlist_folder_from_track_data(
|
||||
playlist_name: str,
|
||||
track_data: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Check playlist-folder existence using Spotify-style track payload."""
|
||||
title = track_data.get('name', '') or track_data.get('track_name', '')
|
||||
artist = _first_artist_name(track_data.get('artists', []))
|
||||
if not artist:
|
||||
artist = str(track_data.get('artist_name', '') or '').strip()
|
||||
return track_exists_in_playlist_folder(playlist_name, artist, title)
|
||||
|
||||
|
||||
def resolve_playlist_folder_mode_for_batch(
|
||||
db: Any,
|
||||
*,
|
||||
playlist_id: str,
|
||||
playlist_name: str,
|
||||
batch_playlist_folder_mode: bool,
|
||||
profile_id: int = 1,
|
||||
source: str = 'spotify',
|
||||
) -> tuple[bool, str]:
|
||||
"""Merge batch flag with persisted mirrored-playlist preference."""
|
||||
if batch_playlist_folder_mode:
|
||||
return True, playlist_name
|
||||
|
||||
if not hasattr(db, 'resolve_mirrored_playlist'):
|
||||
return False, playlist_name
|
||||
|
||||
# Pass the batch's source so numeric upstream ids (e.g. Deezer) resolve by
|
||||
# source instead of colliding with the mirrored-playlists primary key.
|
||||
mirrored = db.resolve_mirrored_playlist(
|
||||
playlist_id, profile_id=profile_id, default_source=source or 'spotify'
|
||||
)
|
||||
if mirrored and mirrored.get('organize_by_playlist'):
|
||||
return True, mirrored.get('name') or playlist_name
|
||||
return False, playlist_name
|
||||
|
||||
|
||||
__all__ = [
|
||||
'candidate_playlist_folder_paths',
|
||||
'track_exists_in_playlist_folder',
|
||||
'track_exists_in_playlist_folder_from_track_data',
|
||||
'resolve_playlist_folder_mode_for_batch',
|
||||
]
|
||||
|
|
@ -171,21 +171,6 @@ def run_post_processing_worker(task_id: str, batch_id: str, deps: PostProcessDep
|
|||
logger.info(f"[Post-Processing] Task {task_id} already completed by stream processor, skipping verification")
|
||||
return
|
||||
|
||||
# RACE GUARD: the monitor sets status -> 'post_processing' immediately
|
||||
# before submitting this worker. If the status is now anything else, the
|
||||
# browser-poll post-processor already took ownership of this task — e.g.
|
||||
# it quarantined the file and requeued the next-best candidate (status
|
||||
# -> 'searching', source identity cleared). Bail WITHOUT marking failed
|
||||
# or notifying batch completion: otherwise we clobber that in-flight
|
||||
# retry with a bogus "missing file or source information" failure while a
|
||||
# parallel attempt is importing the song.
|
||||
if task['status'] != 'post_processing':
|
||||
logger.info(
|
||||
f"[Post-Processing] Task {task_id} no longer in 'post_processing' "
|
||||
f"(now '{task['status']}') — another path took over, skipping"
|
||||
)
|
||||
return
|
||||
|
||||
# Extract file information for verification
|
||||
track_info = task.get('track_info', {})
|
||||
task_filename = task.get('filename') or track_info.get('filename')
|
||||
|
|
@ -453,19 +438,7 @@ def run_post_processing_worker(task_id: str, batch_id: str, deps: PostProcessDep
|
|||
logger.error(f"[Post-Processing] Task {task_id} was completed by stream processor - not marking as failed")
|
||||
return
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
# slskd reported the transfer complete, but the finder never located
|
||||
# the file under the configured download folder. Name the folder we
|
||||
# searched and the two real causes — "still being written" (timing)
|
||||
# or "SoulSync's download path doesn't match slskd's" (the classic
|
||||
# standalone config mismatch) — so the user can self-diagnose instead
|
||||
# of getting an opaque "not found". (Discord: Shdjfgatdif.)
|
||||
_searched_name = os.path.basename((task_filename or '').replace('\\', '/')) or task_filename
|
||||
download_tasks[task_id]['error_message'] = (
|
||||
f"slskd reported '{_searched_name}' downloaded, but it never appeared "
|
||||
f"under the download folder ({download_dir}) after {_file_search_max_retries} "
|
||||
f"checks. Either it's still being written, or SoulSync's download path "
|
||||
f"doesn't match slskd's download directory — they must point at the same folder."
|
||||
)
|
||||
download_tasks[task_id]['error_message'] = f'File not found on disk after {_file_search_max_retries} search attempts. Expected: {os.path.basename(task_filename)}'
|
||||
deps.on_download_completed(batch_id, task_id, False)
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -36,15 +36,6 @@ from utils.logging_config import get_logger
|
|||
# Project logger factory so these lines reach app.log (soulsync.* namespace).
|
||||
logger = get_logger("downloads.status")
|
||||
|
||||
# #836 backstop: how long an slskd error state (Rejected/Failed/Errored/TimedOut)
|
||||
# may persist on a non-manual task before the status formatter gives up on the
|
||||
# retry monitor and marks it failed. The monitor's own retry window is ~15s
|
||||
# (3 × 5s); this is well beyond it so a healthy retry always wins, and it only
|
||||
# fires when the monitor genuinely can't make progress (e.g. a rejected transfer
|
||||
# with no other source) — which otherwise hangs the task at 'downloading 0%'
|
||||
# forever and blocks the whole batch from completing.
|
||||
ERROR_STATE_TERMINAL_GRACE_SECONDS = 60
|
||||
|
||||
|
||||
def _schedule_completion_callback(deps, batch_id: str, task_id: str, success: bool) -> None:
|
||||
"""Fire ``deps.on_download_completed`` on a one-shot daemon thread so
|
||||
|
|
@ -94,10 +85,6 @@ class StatusDeps:
|
|||
run_async: Optional[Callable] = None
|
||||
on_download_completed: Optional[Callable[[str, str, bool], None]] = None
|
||||
get_persistent_download_history: Optional[Callable[[int], list[dict]]] = None
|
||||
# Returns ALL library_history rows with verification_status in
|
||||
# ('unverified', 'force_imported') — no recency limit, so historical
|
||||
# entries are never buried by the general history tail cap.
|
||||
get_unverified_download_history: Optional[Callable[[], list[dict]]] = None
|
||||
|
||||
|
||||
# Streaming sources the engine fallback applies to. Soulseek goes through
|
||||
|
|
@ -353,12 +340,6 @@ def build_batch_status_data(batch_id: str, batch: dict, live_transfers_lookup: d
|
|||
'error_message': task.get('error_message'), # Surface failure reasons to UI
|
||||
'quarantine_entry_id': task.get('quarantine_entry_id'),
|
||||
'has_candidates': bool(task.get('cached_candidates')), # Whether search found results (for clickable review)
|
||||
# 'verified' / 'unverified' / 'force_imported' — set by the
|
||||
# import pipeline once post-processing finishes.
|
||||
'verification_status': task.get('verification_status'),
|
||||
# "2/5" while the quarantine-retry engine walks candidates.
|
||||
'retry_info': task.get('retry_info'),
|
||||
'retry_trigger': task.get('retry_trigger'),
|
||||
}
|
||||
_ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
|
||||
task_filename = task.get('filename') or _ti.get('filename')
|
||||
|
|
@ -413,59 +394,17 @@ def build_batch_status_data(batch_id: str, batch: dict, live_transfers_lookup: d
|
|||
# release the lock.
|
||||
_schedule_completion_callback(deps, batch_id, task_id, False)
|
||||
else:
|
||||
# Normally the retry monitor picks up an errored state and
|
||||
# retries within ~15s. But if it can't make progress — e.g. an
|
||||
# slskd transfer rejected with no other source — the task would
|
||||
# otherwise sit at 'downloading 0%' forever, spam an ERROR every
|
||||
# poll, AND block its batch from ever completing (#836: a rejected
|
||||
# wishlist track, or rejected tracks in an album download).
|
||||
#
|
||||
# Backstop: measure how long the ERROR state has persisted (not
|
||||
# how long the task has downloaded, so a slow-but-healthy transfer
|
||||
# isn't failed). Once it exceeds the monitor's retry window with no
|
||||
# resolution, mark the task failed so the worker frees and the
|
||||
# batch can finish. A working retry transitions the task out of the
|
||||
# error state first, clearing the timer below — so the healthy path
|
||||
# never hits this.
|
||||
# A monitor retry transitions the task (newer
|
||||
# status_change_time), which restarts the window so each
|
||||
# error EPISODE gets a fresh grace. If the monitor never
|
||||
# transitions it (the stuck case), the window keeps growing.
|
||||
err_since = task.get('_error_state_since')
|
||||
if err_since is None or task.get('status_change_time', 0) > err_since:
|
||||
task['_error_state_since'] = err_since = current_time
|
||||
task.pop('_error_state_logged', None)
|
||||
error_age = current_time - err_since
|
||||
# UNIFIED ERROR HANDLING: Let monitor handle errors for consistency
|
||||
# Monitor will detect errored state and trigger retry within 5 seconds
|
||||
logger.error(f"Task {task_id} API shows error state: {state_str} - letting monitor handle retry")
|
||||
|
||||
if error_age > ERROR_STATE_TERMINAL_GRACE_SECONDS:
|
||||
err_msg = live_info.get('errorMessage') or live_info.get('error') or ''
|
||||
task['status'] = 'failed'
|
||||
task['error_message'] = (
|
||||
str(err_msg) if err_msg
|
||||
else f'Download failed (state: {state_str})'
|
||||
)
|
||||
task_status['status'] = 'failed'
|
||||
task_status['error_message'] = task['error_message']
|
||||
logger.warning(
|
||||
f"Task {task_id} stuck in error state '{state_str}' for "
|
||||
f"{error_age:.0f}s with no retry progress — marking failed (#836)"
|
||||
)
|
||||
_schedule_completion_callback(deps, batch_id, task_id, False)
|
||||
# Keep task in current status (downloading/queued) so monitor can detect error
|
||||
# Don't mark as failed here - let the unified retry system handle it
|
||||
if task['status'] in ['searching', 'downloading', 'queued']:
|
||||
task_status['status'] = task['status'] # Keep current status for monitor
|
||||
else:
|
||||
# Within the retry window — keep current status so the monitor
|
||||
# can act. Log once per episode, not every poll, to stop the
|
||||
# 2-second ERROR spam the reporter saw.
|
||||
if not task.get('_error_state_logged'):
|
||||
logger.warning(
|
||||
f"Task {task_id} API shows error state: {state_str} "
|
||||
f"- letting monitor handle retry"
|
||||
)
|
||||
task['_error_state_logged'] = True
|
||||
if task['status'] in ['searching', 'downloading', 'queued']:
|
||||
task_status['status'] = task['status'] # Keep current status for monitor
|
||||
else:
|
||||
task_status['status'] = 'downloading' # Default to downloading for error detection
|
||||
task['status'] = 'downloading'
|
||||
task_status['status'] = 'downloading' # Default to downloading for error detection
|
||||
task['status'] = 'downloading'
|
||||
elif 'Completed' in state_str or 'Succeeded' in state_str:
|
||||
# Verify bytes actually transferred before trusting state string
|
||||
expected_size = live_info.get('size', 0)
|
||||
|
|
@ -715,7 +654,6 @@ def _build_history_download_item(entry: dict) -> dict:
|
|||
'priority': _STATUS_PRIORITY['completed'],
|
||||
'quality': entry.get('quality') or '',
|
||||
'file_path': entry.get('file_path') or '',
|
||||
'verification_status': entry.get('verification_status'),
|
||||
'is_persistent_history': True,
|
||||
}
|
||||
|
||||
|
|
@ -799,16 +737,6 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
|
|||
'status': status,
|
||||
'progress': progress,
|
||||
'error': task.get('error_message'),
|
||||
'verification_status': task.get('verification_status'),
|
||||
# library_history row id (set at import) so the Unverified review
|
||||
# queue can act on a still-live completed task before it becomes
|
||||
# a persistent-history row.
|
||||
'history_id': task.get('history_id'),
|
||||
# Real probed audio quality (mutagen-read from the actual file),
|
||||
# surfaced so the Downloads page can show what was downloaded.
|
||||
'quality': task.get('quality') or '',
|
||||
'retry_info': task.get('retry_info'),
|
||||
'retry_trigger': task.get('retry_trigger'),
|
||||
'batch_id': batch_id,
|
||||
'batch_name': batch.get('playlist_name') or batch.get('album_name') or '',
|
||||
'batch_source': batch.get('source_page') or batch.get('initiated_from') or '',
|
||||
|
|
@ -823,32 +751,6 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
|
|||
'is_persistent_history': False,
|
||||
})
|
||||
|
||||
# --- Unverified history (unconditional, no limit) ---
|
||||
# Always load every library_history row that still needs human confirmation
|
||||
# (verification_status IN ('unverified', 'force_imported')). This is NOT
|
||||
# gated on len(items) < limit so that historical entries from past batches
|
||||
# are visible even during a large active batch that would otherwise exhaust
|
||||
# the limit before the history tail is read. Dedup against live tasks by
|
||||
# identity so a track currently in post-processing isn't shown twice.
|
||||
if deps.get_unverified_download_history is not None:
|
||||
try:
|
||||
unverified_entries = deps.get_unverified_download_history() or []
|
||||
except Exception as exc:
|
||||
logger.debug("[Downloads] unverified history lookup failed: %s", exc)
|
||||
unverified_entries = []
|
||||
|
||||
for entry in unverified_entries:
|
||||
item = _build_history_download_item(entry)
|
||||
identity = _download_identity(item.get('title'), item.get('artist'), item.get('album'))
|
||||
if identity in live_identities:
|
||||
continue
|
||||
items.append(item)
|
||||
live_identities.add(identity)
|
||||
|
||||
# --- General recent-history tail (capped, recency-ordered) ---
|
||||
# Fills in the completed/verified tail so the full Downloads list looks
|
||||
# populated. Gated on len(items) < limit so a busy batch doesn't trigger
|
||||
# an extra DB round-trip when we're already at capacity.
|
||||
if deps.get_persistent_download_history is not None and len(items) < limit:
|
||||
history_limit = min(limit - len(items), _PERSISTENT_HISTORY_TAIL_LIMIT)
|
||||
try:
|
||||
|
|
@ -869,14 +771,7 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
|
|||
live_identities.add(identity)
|
||||
appended_history += 1
|
||||
|
||||
# Sort: active first (by priority), then by timestamp desc within each group.
|
||||
# NOTE: the array order is presentation-only — the Downloads page filters
|
||||
# client-side per tab. What matters is that EVERY live task is present: an
|
||||
# earlier `items[:limit]` truncation (active-first) starved completed/failed/
|
||||
# unverified rows off the end during a busy batch, so those tabs stayed empty
|
||||
# until the batch drained. `limit` now bounds only the persistent-history
|
||||
# tail (handled above); live in-memory tasks are always returned in full
|
||||
# (they're already bounded by the 5-min cleanup automation).
|
||||
# Sort: active first (by priority), then by timestamp desc within each group
|
||||
items.sort(key=lambda x: (x['priority'], -x['timestamp']))
|
||||
|
||||
# Build batch summaries for the batch context panel
|
||||
|
|
@ -904,7 +799,7 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
|
|||
|
||||
return {
|
||||
'success': True,
|
||||
'downloads': items,
|
||||
'downloads': items[:limit],
|
||||
'total': len(items),
|
||||
'batches': batch_summaries,
|
||||
'timestamp': time.time(),
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ a large web_server.py helper that will get its own lift in subsequent PRs.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -26,127 +27,18 @@ from typing import Any, Callable, Optional
|
|||
|
||||
from core.runtime_state import download_batches, download_tasks, tasks_lock
|
||||
from core.spotify_client import Track as SpotifyTrack
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
# Must live under the soulsync.* namespace — handlers only attach there. The
|
||||
# old bare getLogger(__name__) ("core.downloads.task_worker") had no handler,
|
||||
# so the entire [Modal Worker] story — search queries, retry walks, candidate
|
||||
# decisions — never reached app.log.
|
||||
logger = get_logger("downloads.task_worker")
|
||||
|
||||
|
||||
def _resolve_worker_source(username):
|
||||
"""Logical source bucket for a candidate's username (Soulseek peers all
|
||||
collapse to 'soulseek'; streaming sources keep their name). Mirrors the
|
||||
monitor's resolver — imported lazily to avoid an import cycle."""
|
||||
try:
|
||||
from core.downloads.monitor import _resolve_download_source
|
||||
return _resolve_download_source(username)
|
||||
except Exception:
|
||||
return 'soulseek'
|
||||
|
||||
|
||||
def _cand_user_file(candidate):
|
||||
"""Read (username, filename) from a candidate that may be a TrackResult
|
||||
object or a plain dict (tests / cached raw rows)."""
|
||||
if isinstance(candidate, dict):
|
||||
return candidate.get('username'), candidate.get('filename')
|
||||
return getattr(candidate, 'username', None), getattr(candidate, 'filename', None)
|
||||
|
||||
|
||||
def _candidate_ordering():
|
||||
"""Return ``(quality_first, targets)`` for the active search mode + toggle.
|
||||
|
||||
The candidate walk is ordered by the user's profile quality rank
|
||||
(best→worst) instead of confidence-first when EITHER:
|
||||
- best-quality search mode is active (always quality-first), OR
|
||||
- priority mode and the ``rank_candidates_by_quality`` toggle is on
|
||||
(opt-in; default off keeps the byte-for-byte confidence-first walk).
|
||||
|
||||
Quality-first ordering also makes the version-mismatch force-import pick
|
||||
the highest-quality candidate, because that fallback accepts the
|
||||
first-tried (= best-ordered) quarantined entry.
|
||||
|
||||
Fails closed to confidence-first ordering on any error so a profile/DB
|
||||
hiccup never blocks a download. See
|
||||
docs/superpowers/specs/2026-06-14-best-quality-search-mode-design.md.
|
||||
"""
|
||||
try:
|
||||
from core.quality.selection import (
|
||||
load_search_mode,
|
||||
load_profile_targets,
|
||||
load_rank_candidates_by_quality,
|
||||
)
|
||||
if load_search_mode() == 'best_quality' or load_rank_candidates_by_quality():
|
||||
targets, _ = load_profile_targets()
|
||||
return True, targets
|
||||
except Exception as exc:
|
||||
logger.debug("[Modal Worker] quality ordering unavailable: %s", exc)
|
||||
return False, None
|
||||
|
||||
|
||||
def _try_cached_candidates(task_id, batch_id, track, deps):
|
||||
"""Quarantine-retry fast path: attempt the already-found candidates before
|
||||
re-searching anything.
|
||||
|
||||
When a verified-bad file is re-queued, the connection was fine (the file
|
||||
downloaded, it was just the wrong/broken content) — so the next-best pick is
|
||||
almost always already sitting in ``cached_candidates``. Walk those (skipping
|
||||
sources already tried or budget-exhausted) and hand them to the normal
|
||||
download path. Returns True if a download was started; False to fall through
|
||||
to a fresh search (which only happens for a not-yet-searched source).
|
||||
"""
|
||||
with tasks_lock:
|
||||
task = download_tasks.get(task_id)
|
||||
if not task:
|
||||
return False
|
||||
cached = list(task.get('cached_candidates') or [])
|
||||
used = set(task.get('used_sources') or ())
|
||||
exhausted = {str(s).lower() for s in (task.get('exhausted_download_sources') or ())}
|
||||
|
||||
remaining = []
|
||||
for c in cached:
|
||||
uname, fname = _cand_user_file(c)
|
||||
if not uname or not fname:
|
||||
continue
|
||||
if f"{uname}_{fname}" in used:
|
||||
continue
|
||||
if _resolve_worker_source(uname).lower() in exhausted:
|
||||
continue
|
||||
remaining.append(c)
|
||||
|
||||
if not remaining:
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
f"[Modal Worker] Quarantine retry: trying {len(remaining)} cached "
|
||||
f"candidate(s) before re-searching (task {task_id})"
|
||||
)
|
||||
_qf, _qt = _candidate_ordering()
|
||||
return deps.attempt_download_with_candidates(
|
||||
task_id, remaining, track, batch_id, quality_first=_qf, quality_targets=_qt,
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _private_album_bundle_staging_miss_reason(batch_id: Optional[str], deps: Any) -> Optional[str]:
|
||||
"""Return a user-facing miss reason when per-track search should stop.
|
||||
|
||||
Torrent / usenet album batches first download one private staged release,
|
||||
Torrent / usenet / Soulseek album batches first download one private staged release,
|
||||
then each track claims the matching staged file. If that claim fails after
|
||||
the release is already staged, falling through to the normal per-track
|
||||
search only retries release-level sources N times and can keep re-adding
|
||||
the same torrent/NZB. For those two sources we treat the staged release as
|
||||
authoritative for this pass.
|
||||
|
||||
Soulseek is deliberately NOT short-circuited. A Soulseek album bundle stages
|
||||
whichever single folder scored best, and ``album_bundle_partial`` only
|
||||
reflects whether the files found IN that folder downloaded — not whether the
|
||||
folder actually contained every track the album needs. So a track the album
|
||||
needs but that wasn't in the chosen folder would otherwise be marked
|
||||
not_found with no fallback (#743). Unlike torrent/usenet, Soulseek per-track
|
||||
search is a genuine per-file network search — it doesn't re-add a release —
|
||||
so letting these misses fall through to the normal per-track flow (and, in
|
||||
hybrid mode, onward to the next source) is correct and cheap.
|
||||
the same torrent. Treat the staged release as authoritative for this pass.
|
||||
"""
|
||||
if not batch_id:
|
||||
return None
|
||||
|
|
@ -168,7 +60,7 @@ def _private_album_bundle_staging_miss_reason(batch_id: Optional[str], deps: Any
|
|||
batch.get('album_bundle_private_staging')
|
||||
and batch.get('album_bundle_state') == 'staged'
|
||||
and not batch.get('album_bundle_partial')
|
||||
and source in ('torrent', 'usenet')
|
||||
and source in ('torrent', 'usenet', 'soulseek')
|
||||
and (mode == source or (mode == 'hybrid' and hybrid_first == source))
|
||||
):
|
||||
return f'Track was not found in the staged {source} album release'
|
||||
|
|
@ -189,7 +81,6 @@ class TaskWorkerDeps:
|
|||
attempt_download_with_candidates: Callable # (task_id, candidates, track, batch_id) -> bool
|
||||
on_download_completed: Callable # (batch_id, task_id, success) -> None
|
||||
recover_worker_slot: Callable # (batch_id, task_id) -> None
|
||||
try_version_mismatch_fallback: Optional[Callable] = None # (title, artist, task_id, batch_id) -> bool
|
||||
|
||||
|
||||
def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorkerDeps) -> None:
|
||||
|
|
@ -304,26 +195,6 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
|
|||
download_tasks[task_id]['used_sources'] = set()
|
||||
# Else: keep existing used_sources to avoid retrying same failed hosts
|
||||
|
||||
# Cached-first quarantine retry. The monitor sets ``_quarantine_retry``
|
||||
# when a verified-bad file is re-queued; in that case we walk the
|
||||
# already-found candidates before re-searching (the connection was fine,
|
||||
# just the content was wrong). A NON-quarantine entry (fresh download, or
|
||||
# the monitor's dead-connection/stuck retry) instead starts a new search
|
||||
# generation: clear the searched-source memory so each source can be
|
||||
# searched fresh again.
|
||||
with tasks_lock:
|
||||
_t = download_tasks.get(task_id, {})
|
||||
is_quarantine_retry = bool(_t.pop('_quarantine_retry', False))
|
||||
if not is_quarantine_retry:
|
||||
_t.pop('searched_queries', None)
|
||||
if is_quarantine_retry and _try_cached_candidates(task_id, batch_id, track, deps):
|
||||
with tasks_lock:
|
||||
used_filename = download_tasks.get(task_id, {}).get('filename')
|
||||
used_username = download_tasks.get(task_id, {}).get('username')
|
||||
if used_filename and used_username:
|
||||
deps.store_batch_source(batch_id, used_username, used_filename)
|
||||
return
|
||||
|
||||
# 1. Generate multiple search queries (like GUI's generate_smart_search_queries)
|
||||
artist_name = track.artists[0] if track.artists else None
|
||||
track_name = track.name
|
||||
|
|
@ -395,45 +266,12 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
|
|||
seen.add(query.lower())
|
||||
|
||||
search_queries = unique_queries
|
||||
# Expose the query count so the quarantine-retry budget (exhaustive mode)
|
||||
# can size each source's budget as query_count × retries_per_query.
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['query_count'] = len(search_queries)
|
||||
logger.info(f"[Modal Worker] Generated {len(search_queries)} smart search queries for '{track.name}': {search_queries}")
|
||||
logger.info(f"[Modal Worker] About to start search loop for task {task_id} (track: '{track.name}')")
|
||||
|
||||
# Best-quality search mode: the orchestrator already pooled candidates
|
||||
# across every source for each query, so order the candidate walk by the
|
||||
# user's profile quality rank (best→worst). Computed once per task.
|
||||
_best_quality, _quality_targets = _candidate_ordering()
|
||||
|
||||
# 2. Sequential Query Search (matches GUI's start_search_worker_parallel logic)
|
||||
search_diagnostics = [] # Track what happened per query for detailed error messages
|
||||
all_raw_results = [] # Collect raw results across queries for candidate review modal
|
||||
# Sources whose per-source quarantine-retry budget is spent (exhaustive
|
||||
# mode). The monitor sets this when a source gives up; we exclude those
|
||||
# sources from the hybrid search so the chain falls through to the next
|
||||
# source instead of re-fetching the same exhausted one (e.g. Soulseek
|
||||
# keeps returning fresh wrong peers — once its budget is gone, switch to
|
||||
# HiFi/Tidal/…). See monitor.requeue_quarantined_task_for_retry.
|
||||
#
|
||||
# On a quarantine retry we do NOT exclude a source just because it was
|
||||
# searched once: the first run only ran ONE query before starting a
|
||||
# download, so the later queries (e.g. "artist + album") have never hit
|
||||
# that source yet and may surface the correct upload. Instead we remember
|
||||
# which QUERIES already ran (``searched_queries``) and skip re-running
|
||||
# only those — their candidates are walked via the cached-first path
|
||||
# above. The not-yet-searched queries still search the same source, so
|
||||
# every query is exhausted per source before the chain switches sources.
|
||||
# Fresh / dead-connection runs cleared searched_queries above, so they
|
||||
# search everything again.
|
||||
with tasks_lock:
|
||||
_t = download_tasks.get(task_id, {})
|
||||
_exhausted_sources = [str(s) for s in (_t.get('exhausted_download_sources') or ())]
|
||||
_searched_queries = (
|
||||
set(_t.get('searched_queries') or ()) if is_quarantine_retry else set()
|
||||
)
|
||||
for query_index, query in enumerate(search_queries):
|
||||
# Cancellation check before each query
|
||||
with tasks_lock:
|
||||
|
|
@ -446,17 +284,6 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
|
|||
return
|
||||
download_tasks[task_id]['current_query_index'] = query_index
|
||||
|
||||
# Cached-first: a query already run last generation has its candidates
|
||||
# sitting in cache (walked above) — re-searching it is the wasteful
|
||||
# repeat the cached-first design removes. Skip it; the not-yet-run
|
||||
# queries below still search this source.
|
||||
if is_quarantine_retry and query in _searched_queries:
|
||||
logger.debug(
|
||||
f"[Modal Worker] Skipping already-searched query '{query}' "
|
||||
f"(candidates served from cache) for task {task_id}"
|
||||
)
|
||||
continue
|
||||
|
||||
logger.debug(f"[Modal Worker] Query {query_index + 1}/{len(search_queries)}: '{query}'")
|
||||
logger.debug(f"About to call soulseek search for task {task_id}")
|
||||
|
||||
|
|
@ -481,13 +308,9 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
|
|||
_exclude_for_hybrid_album = ['torrent', 'usenet']
|
||||
except Exception as _exc_filter_err:
|
||||
logger.debug("[Modal Worker] album-source-exclusion check failed: %s", _exc_filter_err)
|
||||
# Fold in budget-exhausted sources (per-source quarantine retry).
|
||||
_exclude_sources = list(_exhausted_sources)
|
||||
if _exclude_for_hybrid_album:
|
||||
_exclude_sources.extend(_exclude_for_hybrid_album)
|
||||
# Perform search with timeout
|
||||
tracks_result, _ = deps.run_async(deps.download_orchestrator.search(
|
||||
query, timeout=30, exclude_sources=_exclude_sources or None,
|
||||
query, timeout=30, exclude_sources=_exclude_for_hybrid_album,
|
||||
))
|
||||
logger.debug(f"Search completed for task {task_id}, got {len(tracks_result) if tracks_result else 0} results")
|
||||
|
||||
|
|
@ -496,16 +319,6 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
|
|||
if task_id not in download_tasks:
|
||||
logger.info(f"[Modal Worker] Task {task_id} was deleted after search returned")
|
||||
return
|
||||
# Remember this query ran so a later quarantine retry skips
|
||||
# re-searching it (its candidates are walked via cached-first).
|
||||
# Recorded regardless of result count: re-running a query is
|
||||
# deterministic, so a query that returned nothing won't return
|
||||
# anything new next time either.
|
||||
_sq = download_tasks[task_id].get('searched_queries')
|
||||
if not isinstance(_sq, set):
|
||||
_sq = set()
|
||||
_sq.add(query)
|
||||
download_tasks[task_id]['searched_queries'] = _sq
|
||||
if download_tasks[task_id]['status'] == 'cancelled':
|
||||
logger.warning(f"[Modal Worker] Task {task_id} cancelled after search returned - ignoring results")
|
||||
# Don't call _on_download_completed for cancelled tasks as it can stop monitoring
|
||||
|
|
@ -528,16 +341,11 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
|
|||
logger.warning(f"[Modal Worker] Task {task_id} cancelled before processing candidates")
|
||||
# Don't call _on_download_completed for cancelled tasks as it can stop monitoring
|
||||
return
|
||||
# Store candidates for retry fallback (like GUI). A
|
||||
# later quarantine retry walks these via cached-first
|
||||
# and skips re-searching this query (searched_queries).
|
||||
# Store candidates for retry fallback (like GUI)
|
||||
download_tasks[task_id]['cached_candidates'] = candidates
|
||||
|
||||
# Try to download with these candidates
|
||||
success = deps.attempt_download_with_candidates(
|
||||
task_id, candidates, track, batch_id,
|
||||
quality_first=_best_quality, quality_targets=_quality_targets,
|
||||
)
|
||||
success = deps.attempt_download_with_candidates(task_id, candidates, track, batch_id)
|
||||
if success:
|
||||
# Download initiated successfully - let the download monitoring system handle completion
|
||||
if batch_id:
|
||||
|
|
@ -571,10 +379,7 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
|
|||
# === HYBRID FALLBACK: If primary source failed, try remaining sources directly ===
|
||||
# The orchestrator's hybrid search stops at the first source with results, even if
|
||||
# those results all fail quality filtering. Try remaining sources individually.
|
||||
#
|
||||
# Best-quality mode already searched EVERY source per query (the pool), so this
|
||||
# block would only re-search the same sources — skip it there.
|
||||
if not _best_quality and getattr(deps.download_orchestrator, 'mode', '') == 'hybrid':
|
||||
if getattr(deps.download_orchestrator, 'mode', '') == 'hybrid':
|
||||
try:
|
||||
orch = deps.download_orchestrator
|
||||
hybrid_order = getattr(orch, 'hybrid_order', None) or []
|
||||
|
|
@ -598,12 +403,7 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
|
|||
# (which was definitely tried). If the first was skipped (unconfigured),
|
||||
# the orchestrator would have tried the second — but trying it again is
|
||||
# harmless (streaming sources return fast).
|
||||
_exhausted_lower = {s.lower() for s in _exhausted_sources}
|
||||
remaining_sources = [
|
||||
s for s in hybrid_order[1:]
|
||||
if s in source_clients and source_clients[s]
|
||||
and s.lower() not in _exhausted_lower
|
||||
]
|
||||
remaining_sources = [s for s in hybrid_order[1:] if s in source_clients and source_clients[s]]
|
||||
if remaining_sources:
|
||||
logger.warning(f"[Hybrid Fallback] Primary source had no valid matches. Trying fallback sources: {remaining_sources}")
|
||||
|
||||
|
|
@ -622,9 +422,6 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
|
|||
fb_candidates = deps.get_valid_candidates(fb_results, track, fb_query)
|
||||
if fb_candidates:
|
||||
logger.warning(f"[Hybrid Fallback] {fallback_source} found {len(fb_candidates)} valid candidates!")
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['cached_candidates'] = fb_candidates
|
||||
success = deps.attempt_download_with_candidates(task_id, fb_candidates, track, batch_id)
|
||||
if success:
|
||||
return
|
||||
|
|
@ -639,15 +436,6 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
|
|||
|
||||
# If we get here, all search queries and hybrid fallbacks failed
|
||||
logger.warning(f"[Modal Worker] No valid candidates found for '{track.name}' after trying all {len(search_queries)} queries.")
|
||||
|
||||
# Last-resort: quarantine retry with no new candidates — the retry search
|
||||
# exhausted all sources. If the setting is enabled, accept the best
|
||||
# already-quarantined candidate rather than leaving the track missing.
|
||||
if is_quarantine_retry and deps.try_version_mismatch_fallback:
|
||||
_fallback_artist = track.artists[0] if track.artists else ''
|
||||
if deps.try_version_mismatch_fallback(track.name, _fallback_artist, task_id, batch_id):
|
||||
return # fallback re-dispatched; batch completion handled by reprocess thread
|
||||
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'not_found'
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
"""Assemble a per-track detail view for the download-modal "track detail" modal.
|
||||
|
||||
Merges a live download task with its ``library_history`` record (the same data
|
||||
the Download History cards render) into one dict the frontend modal consumes.
|
||||
Kept pure + importable so the merge + status classification are unit-tested
|
||||
without Flask or the DB; the web endpoint is thin glue around build_track_detail.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
# error_message substrings that mean "quarantined" (file recoverable) rather
|
||||
# than a plain failure. Mirrors the download-modal status renderer.
|
||||
_QUARANTINE_KEYWORDS = (
|
||||
'integrity check failed',
|
||||
'bit depth filter',
|
||||
'verification failed',
|
||||
'quarantin',
|
||||
)
|
||||
|
||||
|
||||
def classify_status_kind(status: str, error_message: str = '') -> str:
|
||||
"""Map a raw task status to a UI 'kind' that drives the modal layout:
|
||||
completed / quarantined / failed / not_found / in_progress.
|
||||
"""
|
||||
s = (status or '').lower()
|
||||
if s == 'completed':
|
||||
return 'completed'
|
||||
if s in ('failed', 'cancelled'):
|
||||
em = (error_message or '').lower()
|
||||
if any(k in em for k in _QUARANTINE_KEYWORDS):
|
||||
return 'quarantined'
|
||||
return 'failed'
|
||||
if s == 'not_found':
|
||||
return 'not_found'
|
||||
return 'in_progress'
|
||||
|
||||
|
||||
def _first_artist(track_info: Dict[str, Any]) -> str:
|
||||
artists = track_info.get('artists') or []
|
||||
if isinstance(artists, list) and artists:
|
||||
first = artists[0]
|
||||
if isinstance(first, dict):
|
||||
return (first.get('name') or '').strip()
|
||||
return str(first).strip()
|
||||
return (track_info.get('artist') or track_info.get('artist_name') or '').strip()
|
||||
|
||||
|
||||
def _album_name(track_info: Dict[str, Any]) -> str:
|
||||
album = track_info.get('album')
|
||||
if isinstance(album, dict):
|
||||
return (album.get('name') or '').strip()
|
||||
return (album or '').strip() if isinstance(album, str) else ''
|
||||
|
||||
|
||||
def build_track_detail(task: Dict[str, Any], history: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Merge a download task (+ optional library_history row) into one detail dict.
|
||||
|
||||
The task supplies live status/source/reason/quarantine id; the history row
|
||||
(when found) supplies the durable provenance — final file path, quality,
|
||||
AcoustID verdict, source, and the expected-vs-downloaded comparison.
|
||||
"""
|
||||
ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
|
||||
status = task.get('status', '') or ''
|
||||
kind = classify_status_kind(status, task.get('error_message', '') or '')
|
||||
|
||||
detail: Dict[str, Any] = {
|
||||
'task_id': task.get('task_id') or task.get('id') or '',
|
||||
'status': status,
|
||||
'status_kind': kind,
|
||||
'title': (ti.get('name') or '').strip(),
|
||||
'artist': _first_artist(ti),
|
||||
'album': _album_name(ti),
|
||||
'source': (task.get('username') or '').strip(),
|
||||
'reason': (task.get('error_message') or '').strip(),
|
||||
'quarantine_entry_id': task.get('quarantine_entry_id') or '',
|
||||
'file_path': (task.get('filename') or '').strip(),
|
||||
'quality': '',
|
||||
'acoustid_result': '',
|
||||
'thumb_url': '',
|
||||
'expected': {},
|
||||
'downloaded': {},
|
||||
}
|
||||
|
||||
if history:
|
||||
detail['file_path'] = (history.get('file_path') or detail['file_path'])
|
||||
detail['quality'] = history.get('quality') or ''
|
||||
detail['acoustid_result'] = history.get('acoustid_result') or ''
|
||||
detail['source'] = history.get('download_source') or detail['source']
|
||||
detail['thumb_url'] = history.get('thumb_url') or ''
|
||||
detail['downloaded'] = {
|
||||
'title': history.get('title') or '',
|
||||
'artist': history.get('artist_name') or '',
|
||||
'album': history.get('album_name') or '',
|
||||
}
|
||||
detail['expected'] = {
|
||||
'title': history.get('source_track_title') or '',
|
||||
'artist': history.get('source_artist') or '',
|
||||
}
|
||||
# Fall back to history values when the task had none.
|
||||
detail['title'] = detail['title'] or detail['downloaded']['title']
|
||||
detail['artist'] = detail['artist'] or detail['downloaded']['artist']
|
||||
detail['album'] = detail['album'] or detail['downloaded']['album']
|
||||
|
||||
return detail
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
"""Recognize a pasted streaming-source track link in the manual download
|
||||
search (#813).
|
||||
|
||||
A user pastes e.g. ``https://tidal.com/track/434945950/u`` instead of typing a
|
||||
query, to grab the exact version. We only recognize sources that download by
|
||||
track ID (Tidal, Qobuz) — the manual search then resolves the link to that
|
||||
track and runs the source's own search so the result is a normal, downloadable
|
||||
candidate (no hand-built download encoding).
|
||||
|
||||
Pure + import-safe: parsing only, no network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def linked_track_id(track: Any) -> str:
|
||||
"""The source track id stamped on a search result, read from
|
||||
``_source_metadata['track_id']`` — the field every ID-downloadable source
|
||||
(Tidal, Qobuz) records. Empty string when absent. ``TrackResult`` has no
|
||||
top-level ``id``, so callers must NOT use ``getattr(t, 'id')`` (that always
|
||||
missed and left the pasted-link bubble a silent no-op — #932)."""
|
||||
meta = getattr(track, '_source_metadata', None)
|
||||
if not isinstance(meta, dict):
|
||||
return ''
|
||||
return str(meta.get('track_id') or '')
|
||||
|
||||
|
||||
def bubble_linked_track_first(tracks: List[Any], link_track_id: str) -> List[Any]:
|
||||
"""Float the result whose source id matches a pasted link to the top so the
|
||||
user sees the EXACT track they linked, not a fuzzy text-search lookalike
|
||||
(#813/#932). Stable + a graceful no-op when no result carries the id."""
|
||||
if not link_track_id or not tracks:
|
||||
return tracks
|
||||
target = str(link_track_id)
|
||||
return sorted(tracks, key=lambda t: linked_track_id(t) != target)
|
||||
|
||||
|
||||
def inject_linked_track_first(
|
||||
tracks: List[Any], linked_result: Any, link_track_id: str
|
||||
) -> List[Any]:
|
||||
"""Put the EXACT linked track first.
|
||||
|
||||
When ``linked_result`` is the track fetched directly by id, prepend it and
|
||||
drop any search duplicate of it — so an obscure track a text search never
|
||||
surfaced is still present and downloadable (#932). When it's None (the source
|
||||
can't fetch one), fall back to bubbling a matching search result. Pure."""
|
||||
if not link_track_id:
|
||||
return tracks
|
||||
target = str(link_track_id)
|
||||
if linked_result is not None:
|
||||
return [linked_result] + [t for t in tracks if linked_track_id(t) != target]
|
||||
return bubble_linked_track_first(tracks, target)
|
||||
|
||||
# host substring → download source id. Only ID-downloadable streaming sources.
|
||||
_HOSTS = (
|
||||
('tidal.com', 'tidal'),
|
||||
('qobuz.com', 'qobuz'),
|
||||
)
|
||||
|
||||
|
||||
def parse_download_track_link(raw: str) -> Optional[Tuple[str, str]]:
|
||||
"""Parse a pasted Tidal/Qobuz track URL into ``(source, track_id)``.
|
||||
|
||||
Returns None when the input isn't a recognized track link (so the caller
|
||||
falls back to a normal text search). Handles the common URL shapes:
|
||||
``tidal.com/track/<id>[/u]``, ``listen.tidal.com/track/<id>``,
|
||||
``tidal.com/browse/track/<id>``, ``open.qobuz.com/track/<id>``,
|
||||
``play.qobuz.com/track/<id>`` — with or without the scheme.
|
||||
"""
|
||||
raw = (raw or '').strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
lowered = raw.lower()
|
||||
if '://' not in raw and not any(h in lowered for h, _ in _HOSTS):
|
||||
return None # not even a URL we care about
|
||||
|
||||
url = raw if '://' in raw else f'https://{raw}'
|
||||
parsed = urlparse(url)
|
||||
host = (parsed.netloc or '').lower()
|
||||
|
||||
source = next((sid for h, sid in _HOSTS if h in host), None)
|
||||
if not source:
|
||||
return None
|
||||
|
||||
segs = [s for s in (parsed.path or '').split('/') if s]
|
||||
for i, seg in enumerate(segs):
|
||||
if seg.lower() == 'track' and i + 1 < len(segs):
|
||||
m = re.match(r'(\d+)', segs[i + 1]) # id may carry a slug/suffix
|
||||
if m:
|
||||
return (source, m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def _first_artist_name(value: Any) -> str:
|
||||
"""First artist name from a list of {'name': ...}/strings, or a single
|
||||
{'name': ...}/string."""
|
||||
if isinstance(value, list):
|
||||
value = value[0] if value else None
|
||||
if isinstance(value, dict):
|
||||
return str(value.get('name') or '')
|
||||
return str(value or '')
|
||||
|
||||
|
||||
def query_from_track_payload(source: str, raw: Any) -> Optional[str]:
|
||||
"""Build a clean ``"artist title"`` search query from a source ``get_track``
|
||||
payload — pure, so the per-source shape parsing is unit-testable without a
|
||||
live client.
|
||||
|
||||
- Tidal: attributes dict (``title`` + optional ``version`` + maybe
|
||||
``artists``/``artist``). The version is appended so a remix link searches
|
||||
for the remix.
|
||||
- Qobuz: track dict (``title`` + ``performer``/``album.artist``).
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
title = (raw.get('title') or '').strip()
|
||||
artist = ''
|
||||
|
||||
if source == 'tidal':
|
||||
version = (raw.get('version') or '').strip()
|
||||
if version and version.lower() not in title.lower():
|
||||
title = f"{title} ({version})" if title else version
|
||||
artist = _first_artist_name(raw.get('artists') or raw.get('artist'))
|
||||
elif source == 'qobuz':
|
||||
artist = _first_artist_name(raw.get('performer'))
|
||||
if not artist:
|
||||
album = raw.get('album') if isinstance(raw.get('album'), dict) else {}
|
||||
artist = _first_artist_name(album.get('artist'))
|
||||
|
||||
query = f"{artist} {title}".strip()
|
||||
return query or (title or None)
|
||||
|
|
@ -93,56 +93,6 @@ def _backfill_album_context(
|
|||
album_context['image_url'] = first['url']
|
||||
|
||||
|
||||
# Placeholder album ids used when no real source album id is known — never queryable.
|
||||
_SENTINEL_ALBUM_IDS = {'explicit_album', 'from_sync_modal', ''}
|
||||
|
||||
|
||||
def backfill_album_context_from_source(
|
||||
album_context: Dict[str, Any],
|
||||
primary_source: Optional[str],
|
||||
get_album_for_source_fn: Any,
|
||||
) -> bool:
|
||||
"""Hydrate a lean album context from the user's PRIMARY metadata source (#915).
|
||||
|
||||
Post-processing's only album backfill (:func:`hydrate_download_metadata`) goes through
|
||||
``spotify_client.get_track_details`` — Spotify-only. An iTunes/Deezer-primary user's
|
||||
download therefore kept a lean context (no ``release_date``), so the path dropped the
|
||||
``$year`` and the date defaulted to ``YYYY-01-01`` — until they ran a Reorganize, which
|
||||
reads the full album from the PRIMARY source. This closes that gap by doing the same:
|
||||
fetch the full album from the primary source and backfill, so a download's pathing/tags
|
||||
match what a later reorganize would produce.
|
||||
|
||||
``get_album_for_source_fn(source, album_id)`` is injected (the real one is
|
||||
``core.metadata.album_tracks.get_album_for_source``) so this stays pure + testable.
|
||||
No-op when: the context is already complete; the primary source is spotify (the existing
|
||||
track-details path covers it); or no real source album id is present. Returns True when
|
||||
it filled anything. Never raises — a backfill failure must not break a download.
|
||||
"""
|
||||
if not isinstance(album_context, dict) or not _album_is_lean(album_context):
|
||||
return False
|
||||
if not primary_source or primary_source == 'spotify':
|
||||
return False
|
||||
album_id = album_context.get('id')
|
||||
if not album_id or str(album_id) in _SENTINEL_ALBUM_IDS:
|
||||
return False
|
||||
try:
|
||||
album = get_album_for_source_fn(primary_source, str(album_id))
|
||||
except Exception as e: # noqa: BLE001 — defensive: never let backfill break a download
|
||||
logger.warning("[Context] primary-source (%s) album backfill failed: %s", primary_source, e)
|
||||
return False
|
||||
if not isinstance(album, dict):
|
||||
return False
|
||||
before = album_context.get('release_date')
|
||||
_backfill_album_context(album_context, {'album': album})
|
||||
if album_context.get('release_date') and album_context.get('release_date') != before:
|
||||
logger.info(
|
||||
"[Context] Hydrated lean album context from primary source %s "
|
||||
"(release_date=%r, total_tracks=%r)",
|
||||
primary_source, album_context.get('release_date'), album_context.get('total_tracks'),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def hydrate_download_metadata(
|
||||
track: Any,
|
||||
track_info: Any,
|
||||
|
|
@ -222,5 +172,4 @@ def hydrate_download_metadata(
|
|||
__all__ = [
|
||||
'ResolvedTrackMetadata',
|
||||
'hydrate_download_metadata',
|
||||
'backfill_album_context_from_source',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -18,14 +18,9 @@ from __future__ import annotations
|
|||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from core.enrichment.services import EnrichmentService, get_service
|
||||
from core.enrichment.unmatched import (
|
||||
SERVICE_ENTITY_SUPPORT,
|
||||
UnmatchedQueryError,
|
||||
supported_entity_types,
|
||||
)
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
|
|
@ -35,33 +30,25 @@ logger = get_logger("enrichment.api")
|
|||
# Hooks the host wires up so the blueprint can persist pause state and
|
||||
# clean up auto-pause / yield-override sets without circular imports.
|
||||
_config_set: Optional[Callable[[str, Any], None]] = None
|
||||
_config_get: Optional[Callable[[str, Any], Any]] = None
|
||||
_auto_paused_discard: Optional[Callable[[str], None]] = None
|
||||
_yield_override_add: Optional[Callable[[str], None]] = None
|
||||
_db_getter: Optional[Callable[[], Any]] = None
|
||||
|
||||
|
||||
def configure(
|
||||
*,
|
||||
config_set: Optional[Callable[[str, Any], None]] = None,
|
||||
config_get: Optional[Callable[[str, Any], Any]] = None,
|
||||
auto_paused_discard: Optional[Callable[[str], None]] = None,
|
||||
yield_override_add: Optional[Callable[[str], None]] = None,
|
||||
db_getter: Optional[Callable[[], Any]] = None,
|
||||
) -> None:
|
||||
"""Wire host-side mutators that the generic routes call after pause/resume.
|
||||
|
||||
Each is optional — pass None for hosts that don't have a corresponding
|
||||
mechanism (e.g. tests). ``db_getter`` returns the live ``MusicDatabase``
|
||||
for the unmatched-browser routes; ``config_get``/``config_set`` read and
|
||||
write the per-worker priority override.
|
||||
mechanism (e.g. tests).
|
||||
"""
|
||||
global _config_set, _config_get, _auto_paused_discard, _yield_override_add, _db_getter
|
||||
global _config_set, _auto_paused_discard, _yield_override_add
|
||||
_config_set = config_set
|
||||
_config_get = config_get
|
||||
_auto_paused_discard = auto_paused_discard
|
||||
_yield_override_add = yield_override_add
|
||||
_db_getter = db_getter
|
||||
|
||||
|
||||
def _persist_paused(service: EnrichmentService, paused: bool) -> None:
|
||||
|
|
@ -166,133 +153,4 @@ def create_blueprint() -> Blueprint:
|
|||
logger.error("Error resuming %s worker: %s", service.id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@bp.route('/api/enrichment/<service_id>/breakdown', methods=['GET'])
|
||||
def enrichment_breakdown(service_id: str):
|
||||
"""matched / not_found / pending tallies per entity type for the modal."""
|
||||
if service_id not in SERVICE_ENTITY_SUPPORT:
|
||||
return jsonify({'error': f'Unknown enrichment service: {service_id}'}), 404
|
||||
if _db_getter is None:
|
||||
return jsonify({'error': 'database unavailable'}), 503
|
||||
try:
|
||||
db = _db_getter()
|
||||
breakdown = {
|
||||
entity: db.get_enrichment_breakdown(service_id, entity)
|
||||
for entity in supported_entity_types(service_id)
|
||||
}
|
||||
return jsonify({'service': service_id, 'breakdown': breakdown}), 200
|
||||
except UnmatchedQueryError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
logger.error("Error building %s enrichment breakdown: %s", service_id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@bp.route('/api/enrichment/<service_id>/unmatched', methods=['GET'])
|
||||
def enrichment_unmatched(service_id: str):
|
||||
"""Paginated list of items this source hasn't matched (for manual match).
|
||||
|
||||
Query params: ``entity_type`` (artist|album|track), ``status``
|
||||
(not_found|pending|unmatched), ``q`` (name search), ``limit``, ``offset``.
|
||||
"""
|
||||
if service_id not in SERVICE_ENTITY_SUPPORT:
|
||||
return jsonify({'error': f'Unknown enrichment service: {service_id}'}), 404
|
||||
if _db_getter is None:
|
||||
return jsonify({'error': 'database unavailable'}), 503
|
||||
|
||||
entity_type = (request.args.get('entity_type') or 'artist').strip()
|
||||
status = (request.args.get('status') or 'not_found').strip()
|
||||
query = (request.args.get('q') or '').strip() or None
|
||||
try:
|
||||
limit = int(request.args.get('limit', 50))
|
||||
offset = int(request.args.get('offset', 0))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'error': 'limit/offset must be integers'}), 400
|
||||
|
||||
try:
|
||||
result = _db_getter().get_enrichment_unmatched(
|
||||
service_id, entity_type, status, query, limit, offset
|
||||
)
|
||||
except UnmatchedQueryError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
logger.error("Error listing %s unmatched %ss: %s", service_id, entity_type, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
result.update({
|
||||
'service': service_id,
|
||||
'entity_type': entity_type,
|
||||
'status': status,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'entity_types': list(supported_entity_types(service_id)),
|
||||
})
|
||||
return jsonify(result), 200
|
||||
|
||||
@bp.route('/api/enrichment/<service_id>/retry', methods=['POST'])
|
||||
def enrichment_retry(service_id: str):
|
||||
"""Re-queue item(s) so the worker re-attempts them.
|
||||
|
||||
Body: ``entity_type`` (artist|album|track), ``scope`` (item|failed),
|
||||
``entity_id`` (required when scope='item'). 'failed' re-queues every
|
||||
not_found item of that entity type.
|
||||
"""
|
||||
if service_id not in SERVICE_ENTITY_SUPPORT:
|
||||
return jsonify({'error': f'Unknown enrichment service: {service_id}'}), 404
|
||||
if _db_getter is None:
|
||||
return jsonify({'error': 'database unavailable'}), 503
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
entity_type = (data.get('entity_type') or 'artist').strip()
|
||||
scope = (data.get('scope') or 'item').strip()
|
||||
entity_id = data.get('entity_id')
|
||||
try:
|
||||
count = _db_getter().reset_enrichment(service_id, entity_type, scope, entity_id)
|
||||
except UnmatchedQueryError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
logger.error("Error re-queuing %s %s (%s): %s", service_id, entity_type, scope, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return jsonify({'success': True, 'reset': count, 'service': service_id,
|
||||
'entity_type': entity_type, 'scope': scope}), 200
|
||||
|
||||
@bp.route('/api/enrichment/<service_id>/priority', methods=['GET'])
|
||||
def enrichment_get_priority(service_id: str):
|
||||
"""Return the pinned 'process this group first' entity for a worker."""
|
||||
if service_id not in SERVICE_ENTITY_SUPPORT:
|
||||
return jsonify({'error': f'Unknown enrichment service: {service_id}'}), 404
|
||||
priority = ''
|
||||
if _config_get is not None:
|
||||
try:
|
||||
priority = (_config_get(f'{service_id}_enrichment_priority', '') or '').strip().lower()
|
||||
except Exception as e:
|
||||
logger.debug("reading %s priority: %s", service_id, e)
|
||||
if priority not in supported_entity_types(service_id):
|
||||
priority = ''
|
||||
return jsonify({'service': service_id, 'priority': priority,
|
||||
'entity_types': list(supported_entity_types(service_id))}), 200
|
||||
|
||||
@bp.route('/api/enrichment/<service_id>/priority', methods=['POST'])
|
||||
def enrichment_set_priority(service_id: str):
|
||||
"""Pin (or clear) the entity type the worker should process first.
|
||||
|
||||
Body: ``entity`` = 'artist'|'album'|'track' to pin, or '' / null / 'none'
|
||||
to clear. Must be an entity type the source actually enriches.
|
||||
"""
|
||||
if service_id not in SERVICE_ENTITY_SUPPORT:
|
||||
return jsonify({'error': f'Unknown enrichment service: {service_id}'}), 404
|
||||
if _config_set is None:
|
||||
return jsonify({'error': 'config unavailable'}), 503
|
||||
data = request.get_json(silent=True) or {}
|
||||
entity = (data.get('entity') or '').strip().lower()
|
||||
if entity in ('none', 'clear'):
|
||||
entity = ''
|
||||
if entity and entity not in supported_entity_types(service_id):
|
||||
return jsonify({'error': f'{service_id} does not enrich {entity!r}'}), 400
|
||||
try:
|
||||
_config_set(f'{service_id}_enrichment_priority', entity)
|
||||
except Exception as e:
|
||||
logger.error("setting %s priority: %s", service_id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
logger.info("%s enrichment priority set to %r via UI", service_id, entity or '(none)')
|
||||
return jsonify({'success': True, 'service': service_id, 'priority': entity}), 200
|
||||
|
||||
return bp
|
||||
|
|
|
|||
|
|
@ -1,284 +0,0 @@
|
|||
"""Read-side helpers for browsing the items an enrichment source hasn't matched.
|
||||
|
||||
The dashboard "Manage Enrichment Workers" modal lists, per source, the
|
||||
artists / albums / tracks whose ``<service>_match_status`` is ``'not_found'``
|
||||
(or still pending = ``NULL``) so the user can manually match them. Every
|
||||
enrichment source writes a uniform ``<service>_match_status`` column, so one
|
||||
parametric query serves all 11 workers.
|
||||
|
||||
This module owns the column mapping and SQL construction. ``service`` and
|
||||
``entity_type`` are whitelisted against :data:`SERVICE_ENTITY_SUPPORT` and the
|
||||
entity table map before any column name is interpolated — user-supplied values
|
||||
(the search term, pagination) are always bound parameters, never interpolated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
# Which entity types each enrichment source covers. Mirrors the authoritative
|
||||
# ``_SERVICE_ID_COLUMNS`` map in web_server.py (used by manual-match), kept here
|
||||
# so the unmatched browser is self-contained and unit-testable. Singular keys
|
||||
# ('artist'/'album'/'track') match the manual-match entity_type vocabulary.
|
||||
SERVICE_ENTITY_SUPPORT = {
|
||||
'spotify': ('artist', 'album', 'track'),
|
||||
'musicbrainz': ('artist', 'album', 'track'),
|
||||
'deezer': ('artist', 'album', 'track'),
|
||||
'audiodb': ('artist', 'album', 'track'),
|
||||
'discogs': ('artist', 'album'), # no track-level id column
|
||||
'itunes': ('artist', 'album', 'track'),
|
||||
'lastfm': ('artist', 'album', 'track'),
|
||||
'genius': ('artist', 'track'), # no album-level id column
|
||||
'tidal': ('artist', 'album', 'track'),
|
||||
'qobuz': ('artist', 'album', 'track'),
|
||||
'amazon': ('artist', 'album', 'track'),
|
||||
# Relationship enrichment (not a metadata source): the Similar Artists worker
|
||||
# only operates at the artist level, and its <service>_match_status tracks
|
||||
# whether MusicMap similars were fetched (not a source-id match). So the
|
||||
# breakdown / unmatched list here means "artists we have / don't have
|
||||
# similars for" — informative, even though there's no manual-match action.
|
||||
'similar_artists': ('artist',),
|
||||
}
|
||||
|
||||
# entity_type -> table / display-name column / image expression / optional join
|
||||
# / parent-context expression (the artist an album belongs to; the album a
|
||||
# track belongs to) so the UI can disambiguate same-named items.
|
||||
# tracks carry no artwork column of their own, so we borrow the parent album's.
|
||||
_ENTITY_TABLE = {
|
||||
'artist': {
|
||||
'table': 'artists', 'name': 'name',
|
||||
'image': 'artists.thumb_url', 'join': '', 'parent': None,
|
||||
},
|
||||
'album': {
|
||||
'table': 'albums', 'name': 'title',
|
||||
'image': 'albums.thumb_url',
|
||||
'join': 'LEFT JOIN artists par ON albums.artist_id = par.id',
|
||||
'parent': 'par.name',
|
||||
},
|
||||
'track': {
|
||||
'table': 'tracks', 'name': 'title',
|
||||
'image': 'al.thumb_url',
|
||||
'join': 'LEFT JOIN albums al ON tracks.album_id = al.id',
|
||||
'parent': 'al.title',
|
||||
},
|
||||
}
|
||||
|
||||
# 'unmatched' = not yet matched at all (pending OR explicitly not_found).
|
||||
VALID_STATUSES = ('not_found', 'pending', 'unmatched')
|
||||
|
||||
# Hard cap so a malicious/buggy caller can't ask for the whole library at once.
|
||||
MAX_LIMIT = 200
|
||||
|
||||
|
||||
class UnmatchedQueryError(ValueError):
|
||||
"""Raised for an unknown service / unsupported entity type / bad status."""
|
||||
|
||||
|
||||
def supported_entity_types(service: str) -> Tuple[str, ...]:
|
||||
"""Return the entity types a source enriches, or () for an unknown source."""
|
||||
return SERVICE_ENTITY_SUPPORT.get(service, ())
|
||||
|
||||
|
||||
def match_status_column(service: str) -> str:
|
||||
return f"{service}_match_status"
|
||||
|
||||
|
||||
def last_attempted_column(service: str) -> str:
|
||||
return f"{service}_last_attempted"
|
||||
|
||||
|
||||
def _validate(service: str, entity_type: str) -> None:
|
||||
support = SERVICE_ENTITY_SUPPORT.get(service)
|
||||
if support is None:
|
||||
raise UnmatchedQueryError(f"Unknown enrichment service: {service!r}")
|
||||
if entity_type not in support:
|
||||
raise UnmatchedQueryError(
|
||||
f"{service} does not enrich {entity_type!r} entities"
|
||||
)
|
||||
if entity_type not in _ENTITY_TABLE: # defensive — support map drift
|
||||
raise UnmatchedQueryError(f"No table mapping for entity type {entity_type!r}")
|
||||
|
||||
|
||||
def _status_predicate(service: str, status: str, qualifier: str) -> str:
|
||||
"""SQL predicate selecting rows in the requested match state.
|
||||
|
||||
``qualifier`` (the table name/alias) is always prefixed so the predicate is
|
||||
unambiguous even when the query joins a second table that also carries a
|
||||
``<service>_match_status`` column (tracks LEFT JOIN albums).
|
||||
"""
|
||||
col = f"{qualifier}.{match_status_column(service)}"
|
||||
if status == 'not_found':
|
||||
return f"{col} = 'not_found'"
|
||||
if status == 'pending':
|
||||
return f"{col} IS NULL"
|
||||
# 'unmatched'
|
||||
return f"({col} IS NULL OR {col} = 'not_found')"
|
||||
|
||||
|
||||
def build_unmatched_query(
|
||||
service: str,
|
||||
entity_type: str,
|
||||
status: str = 'not_found',
|
||||
query: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> Tuple[str, List]:
|
||||
"""Build the paginated SELECT for one (service, entity_type, status) view.
|
||||
|
||||
Returns ``(sql, params)``. Selected columns: id, name, image_url, status,
|
||||
last_attempted.
|
||||
"""
|
||||
_validate(service, entity_type)
|
||||
if status not in VALID_STATUSES:
|
||||
raise UnmatchedQueryError(f"Invalid status: {status!r}")
|
||||
|
||||
meta = _ENTITY_TABLE[entity_type]
|
||||
table, name_col, image_expr, join = (
|
||||
meta['table'], meta['name'], meta['image'], meta['join'],
|
||||
)
|
||||
ms = match_status_column(service)
|
||||
la = last_attempted_column(service)
|
||||
|
||||
where = [_status_predicate(service, status, table)]
|
||||
params: List = []
|
||||
if query:
|
||||
where.append(f"{table}.{name_col} LIKE ?")
|
||||
params.append(f"%{query}%")
|
||||
|
||||
parent_expr = meta.get('parent')
|
||||
parent_select = f"{parent_expr} AS parent" if parent_expr else "NULL AS parent"
|
||||
sql = (
|
||||
f"SELECT {table}.id AS id, {table}.{name_col} AS name, "
|
||||
f"{image_expr} AS image_url, {parent_select}, {table}.{ms} AS status, "
|
||||
f"{table}.{la} AS last_attempted "
|
||||
f"FROM {table} {join} "
|
||||
f"WHERE {' AND '.join(where)} "
|
||||
f"ORDER BY {table}.{name_col} COLLATE NOCASE "
|
||||
f"LIMIT ? OFFSET ?"
|
||||
).replace(' ', ' ')
|
||||
|
||||
params.append(_clamp_limit(limit))
|
||||
params.append(max(int(offset or 0), 0))
|
||||
return sql, params
|
||||
|
||||
|
||||
def build_count_query(
|
||||
service: str,
|
||||
entity_type: str,
|
||||
status: str = 'not_found',
|
||||
query: Optional[str] = None,
|
||||
) -> Tuple[str, List]:
|
||||
"""Build the COUNT(*) matching :func:`build_unmatched_query`'s filters."""
|
||||
_validate(service, entity_type)
|
||||
if status not in VALID_STATUSES:
|
||||
raise UnmatchedQueryError(f"Invalid status: {status!r}")
|
||||
|
||||
meta = _ENTITY_TABLE[entity_type]
|
||||
table, name_col = meta['table'], meta['name']
|
||||
|
||||
where = [_status_predicate(service, status, table)]
|
||||
params: List = []
|
||||
if query:
|
||||
where.append(f"{table}.{name_col} LIKE ?")
|
||||
params.append(f"%{query}%")
|
||||
|
||||
sql = f"SELECT COUNT(*) FROM {table} WHERE {' AND '.join(where)}"
|
||||
return sql, params
|
||||
|
||||
|
||||
# Reset scopes for re-queuing items so the worker re-attempts them.
|
||||
RESET_SCOPES = ('item', 'failed')
|
||||
|
||||
|
||||
def build_reset_query(
|
||||
service: str,
|
||||
entity_type: str,
|
||||
scope: str = 'item',
|
||||
entity_id=None,
|
||||
) -> Tuple[str, List]:
|
||||
"""Build the UPDATE that re-queues item(s) for enrichment.
|
||||
|
||||
Re-queuing means clearing ``<service>_match_status`` back to NULL (and
|
||||
``<service>_last_attempted`` to NULL): every worker's pending query selects
|
||||
``match_status IS NULL`` first, so the item is retried on the next pass.
|
||||
Nulling last_attempted alone is NOT enough — the not_found retry path uses
|
||||
``last_attempted < cutoff`` and ``NULL < cutoff`` is false, so the item
|
||||
would never be picked up.
|
||||
|
||||
* scope='item' -> a single row (requires entity_id)
|
||||
* scope='failed' -> every 'not_found' row for this entity type
|
||||
"""
|
||||
_validate(service, entity_type)
|
||||
if scope not in RESET_SCOPES:
|
||||
raise UnmatchedQueryError(f"Invalid reset scope: {scope!r}")
|
||||
|
||||
meta = _ENTITY_TABLE[entity_type]
|
||||
table = meta['table']
|
||||
ms = match_status_column(service)
|
||||
la = last_attempted_column(service)
|
||||
set_parts = [f"{ms} = NULL", f"{la} = NULL"]
|
||||
|
||||
# Also forget the stored source ID so re-matching actually RE-RESOLVES the
|
||||
# entity. Without this, the worker hits its existing-id short-circuit, sees
|
||||
# the old (possibly WRONG) id and just re-confirms it — which is why "click
|
||||
# to rematch" never fixed a mis-matched same-name artist (#868). Tracks keep
|
||||
# their ids in file tags rather than a column, so only artist/album clear one.
|
||||
if entity_type in ('artist', 'album'):
|
||||
try:
|
||||
from core.source_ids import id_column
|
||||
id_col = id_column(service, entity_type)
|
||||
except Exception:
|
||||
id_col = None
|
||||
if id_col:
|
||||
set_parts.append(f"{id_col} = NULL")
|
||||
set_clause = "SET " + ", ".join(set_parts)
|
||||
|
||||
if scope == 'item':
|
||||
if not entity_id:
|
||||
raise UnmatchedQueryError("entity_id is required for an item reset")
|
||||
return f"UPDATE {table} {set_clause} WHERE id = ?", [entity_id]
|
||||
# 'failed' — re-queue everything this source explicitly gave up on.
|
||||
return f"UPDATE {table} {set_clause} WHERE {ms} = 'not_found'", []
|
||||
|
||||
|
||||
def build_breakdown_query(service: str, entity_type: str) -> Tuple[str, List]:
|
||||
"""Build the matched / not_found / pending / total tally for one entity type."""
|
||||
_validate(service, entity_type)
|
||||
meta = _ENTITY_TABLE[entity_type]
|
||||
table = meta['table']
|
||||
ms = f"{table}.{match_status_column(service)}"
|
||||
sql = (
|
||||
"SELECT "
|
||||
f"SUM(CASE WHEN {ms} = 'matched' THEN 1 ELSE 0 END) AS matched, "
|
||||
f"SUM(CASE WHEN {ms} = 'not_found' THEN 1 ELSE 0 END) AS not_found, "
|
||||
f"SUM(CASE WHEN {ms} IS NULL THEN 1 ELSE 0 END) AS pending, "
|
||||
f"COUNT(*) AS total "
|
||||
f"FROM {table}"
|
||||
)
|
||||
return sql, []
|
||||
|
||||
|
||||
def _clamp_limit(limit) -> int:
|
||||
try:
|
||||
n = int(limit)
|
||||
except (TypeError, ValueError):
|
||||
return 50
|
||||
if n <= 0:
|
||||
return 50
|
||||
return min(n, MAX_LIMIT)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'SERVICE_ENTITY_SUPPORT',
|
||||
'VALID_STATUSES',
|
||||
'MAX_LIMIT',
|
||||
'UnmatchedQueryError',
|
||||
'supported_entity_types',
|
||||
'match_status_column',
|
||||
'last_attempted_column',
|
||||
'build_unmatched_query',
|
||||
'build_count_query',
|
||||
'build_breakdown_query',
|
||||
'build_reset_query',
|
||||
'RESET_SCOPES',
|
||||
]
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
"""Enrichment-worker yield policy: who pauses while the user's foreground
|
||||
work is running.
|
||||
|
||||
Background enrichment workers share external API budgets with the foreground
|
||||
pipelines — most painfully MusicBrainz (~1 req/s per IP), where a worker
|
||||
grinding through the library can starve the import pipeline's per-track
|
||||
lookups into multi-minute crawls (measured: ~4m15s/track vs the normal ~20s).
|
||||
|
||||
Policy (set with Boulder, 2026-06-06):
|
||||
- downloads active -> EVERYTHING yields (post-processing touches every
|
||||
metadata source: MusicBrainz, Spotify, iTunes,
|
||||
Deezer, Discogs, Last.fm, Genius, ...)
|
||||
- discovery active -> the API-contention five yield (discovery hammers
|
||||
the track-matching sources only)
|
||||
Workers the user explicitly resumed mid-yield are honored upstream (the
|
||||
override set lives in web_server's loop, as does the user-paused bookkeeping).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
# Everything that yields during active downloads. listening-stats (talks only
|
||||
# to the local media server) and repair (user-scheduled job runner, not a
|
||||
# background API drip) intentionally keep running.
|
||||
ALL_YIELD_WORKERS = (
|
||||
'musicbrainz', 'audiodb', 'discogs', 'deezer',
|
||||
'spotify-enrichment', 'itunes-enrichment', 'lastfm-enrichment',
|
||||
'genius-enrichment', 'tidal-enrichment', 'qobuz-enrichment',
|
||||
'amazon-enrichment', 'similar_artists', 'hydrabase', 'soulid',
|
||||
)
|
||||
|
||||
# The sources discovery contends with (track matching APIs).
|
||||
API_CONTENTION_WORKERS = frozenset({
|
||||
'spotify-enrichment', 'itunes-enrichment', 'deezer', 'discogs', 'hydrabase',
|
||||
})
|
||||
|
||||
# Discovery state phases that mean "nothing running" (idle or terminal).
|
||||
_INACTIVE_PHASES = frozenset({'', 'idle', 'discovered', 'error', 'failed', 'cancelled'})
|
||||
|
||||
|
||||
def worker_yield_reason(name: str, downloading: bool, discovering: bool) -> Optional[str]:
|
||||
"""Why ``name`` should be paused right now, or None to run.
|
||||
Downloads outrank discovery so the label reflects the stronger cause."""
|
||||
if name not in ALL_YIELD_WORKERS:
|
||||
return None
|
||||
if downloading:
|
||||
return 'downloads'
|
||||
if discovering and name in API_CONTENTION_WORKERS:
|
||||
return 'discovery'
|
||||
return None
|
||||
|
||||
|
||||
def discovery_state_active(state: dict) -> bool:
|
||||
"""True when a per-playlist discovery state dict represents live work."""
|
||||
phase = str((state or {}).get('phase', '') or '').lower()
|
||||
return phase not in _INACTIVE_PHASES
|
||||
|
|
@ -1 +0,0 @@
|
|||
"""Data export builders."""
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
"""Export an artist roster — watchlist OR library — to JSON / CSV / plain text
|
||||
(corruption's request).
|
||||
|
||||
Pure shaping + formatting so it's the single source of truth and unit-testable —
|
||||
web_server fetches the artists (normalizing each source's fields onto the canonical
|
||||
``*_artist_id`` keys below) and hands them here; the UI just picks options and
|
||||
downloads. Always exports the name + whatever source IDs each artist has;
|
||||
``include_links`` adds external discography URLs; ``extra_fields`` passes through
|
||||
source-specific extras (e.g. library album/track counts) in a stable order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Canonical id field → external URL builder.
|
||||
_LINKS = {
|
||||
'spotify_artist_id': lambda i: f'https://open.spotify.com/artist/{i}',
|
||||
'musicbrainz_artist_id': lambda i: f'https://musicbrainz.org/artist/{i}',
|
||||
'deezer_artist_id': lambda i: f'https://www.deezer.com/artist/{i}',
|
||||
'discogs_artist_id': lambda i: f'https://www.discogs.com/artist/{i}',
|
||||
'itunes_artist_id': lambda i: f'https://music.apple.com/artist/{i}',
|
||||
'tidal_artist_id': lambda i: f'https://tidal.com/artist/{i}',
|
||||
'qobuz_artist_id': lambda i: f'https://www.qobuz.com/artist/{i}',
|
||||
}
|
||||
# Stable order so CSV columns + JSON keys are deterministic. amazon carries an id
|
||||
# but no clean public URL.
|
||||
_ID_FIELDS = ['spotify_artist_id', 'musicbrainz_artist_id', 'deezer_artist_id',
|
||||
'discogs_artist_id', 'itunes_artist_id', 'tidal_artist_id',
|
||||
'qobuz_artist_id', 'amazon_artist_id']
|
||||
|
||||
VALID_FORMATS = ('json', 'csv', 'txt')
|
||||
|
||||
|
||||
def _name(a: Dict[str, Any]) -> str:
|
||||
return str(a.get('artist_name') or a.get('name') or '').strip()
|
||||
|
||||
|
||||
def _short(field: str) -> str:
|
||||
return field.replace('_artist_id', '')
|
||||
|
||||
|
||||
def _row(a: Dict[str, Any], include_links: bool, extra_fields: List[str]) -> Dict[str, Any]:
|
||||
row: Dict[str, Any] = {'name': _name(a)}
|
||||
for f in _ID_FIELDS:
|
||||
if a.get(f):
|
||||
row[f] = str(a[f])
|
||||
for f in extra_fields:
|
||||
if a.get(f) not in (None, ''):
|
||||
row[f] = a[f]
|
||||
if include_links:
|
||||
links = {_short(f): b(a[f]) for f, b in _LINKS.items() if a.get(f)}
|
||||
if links:
|
||||
row['links'] = links
|
||||
return row
|
||||
|
||||
|
||||
def build_artist_export(artists: Optional[List[Dict[str, Any]]],
|
||||
fmt: str = 'json', include_links: bool = False,
|
||||
extra_fields: Optional[List[str]] = None) -> str:
|
||||
"""Return the roster serialized in ``fmt`` (json | csv | txt).
|
||||
|
||||
- ``txt`` → one artist name per line.
|
||||
- ``csv`` → name + each source-id column + ``extra_fields`` columns (+ a
|
||||
*_url column per service when ``include_links``).
|
||||
- ``json`` → a list of objects: name, present source ids, present extras, and
|
||||
a ``links`` map when ``include_links``.
|
||||
"""
|
||||
artists = artists or []
|
||||
extra_fields = list(extra_fields or [])
|
||||
fmt = (fmt or 'json').lower()
|
||||
if fmt not in VALID_FORMATS:
|
||||
fmt = 'json'
|
||||
|
||||
if fmt == 'txt':
|
||||
return '\n'.join(n for n in (_name(a) for a in artists) if n)
|
||||
|
||||
if fmt == 'csv':
|
||||
cols = ['name'] + _ID_FIELDS + extra_fields
|
||||
if include_links:
|
||||
cols += [f'{_short(f)}_url' for f in _LINKS]
|
||||
out = io.StringIO()
|
||||
w = csv.writer(out)
|
||||
w.writerow(cols)
|
||||
for a in artists:
|
||||
line = [_name(a)] + [str(a.get(f) or '') for f in _ID_FIELDS]
|
||||
line += [str(a.get(f) if a.get(f) is not None else '') for f in extra_fields]
|
||||
if include_links:
|
||||
line += [_LINKS[f](a[f]) if a.get(f) else '' for f in _LINKS]
|
||||
w.writerow(line)
|
||||
return out.getvalue()
|
||||
|
||||
return json.dumps([_row(a, include_links, extra_fields) for a in artists],
|
||||
indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def export_mime_and_ext(fmt: str):
|
||||
"""(content-type, file extension) for a format."""
|
||||
return {
|
||||
'json': ('application/json', 'json'),
|
||||
'csv': ('text/csv', 'csv'),
|
||||
'txt': ('text/plain', 'txt'),
|
||||
}.get((fmt or 'json').lower(), ('application/json', 'json'))
|
||||
|
||||
|
||||
__all__ = ['build_artist_export', 'export_mime_and_ext', 'VALID_FORMATS']
|
||||
|
|
@ -1,364 +0,0 @@
|
|||
"""Wire the real cheapest-first sources for the export MBID waterfall (#903).
|
||||
|
||||
``mbid_resolver`` is the pure waterfall; this module supplies the real I/O behind each
|
||||
source and assembles the ``resolve_fn`` the export job uses:
|
||||
|
||||
1. **cache** — ``recording_mbid_cache`` (persistent (artist,title)->mbid).
|
||||
2. **DB** — a text-matched library track's ``tracks.musicbrainz_recording_id``.
|
||||
3. **file** — ``MUSICBRAINZ_RECORDING_ID`` tag of that track's file (when the DB row had
|
||||
no recording id but the file was tagged on import).
|
||||
4. **MusicBrainz** — live ``match_recording(track, artist)`` (rate-limited tail).
|
||||
|
||||
Every source is wrapped so any failure (missing table, unreadable file, MB timeout) returns
|
||||
None — the waterfall just falls through, the export never breaks. ``build_resolve_fn`` also
|
||||
writes a fresh non-cache hit back to the cache so the next export of the same song is free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
from core.exports.mbid_resolver import (
|
||||
SRC_CACHE,
|
||||
SRC_DB,
|
||||
SRC_FILE,
|
||||
SRC_MUSICBRAINZ,
|
||||
normalize_key,
|
||||
resolve_recording_mbid,
|
||||
)
|
||||
|
||||
logger = get_logger("exports.export_sources")
|
||||
|
||||
|
||||
def _db_match(artist: str, title: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Text-match a library track by (artist, title); return (recording_mbid, file_path).
|
||||
Either may be None. Fail-safe — any DB error returns (None, None)."""
|
||||
if not title:
|
||||
return (None, None)
|
||||
try:
|
||||
from database.music_database import get_database
|
||||
db = get_database()
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT t.musicbrainz_recording_id, t.file_path "
|
||||
"FROM tracks t JOIN artists a ON t.artist_id = a.id "
|
||||
"WHERE LOWER(t.title) = LOWER(?) AND LOWER(a.name) = LOWER(?) "
|
||||
"LIMIT 1",
|
||||
(title, artist),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return (None, None)
|
||||
mbid = row[0] if not hasattr(row, "keys") else row["musicbrainz_recording_id"]
|
||||
fpath = row[1] if not hasattr(row, "keys") else row["file_path"]
|
||||
return ((mbid or None), (fpath or None))
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug(f"export db_match failed for '{artist} - {title}': {exc}")
|
||||
return (None, None)
|
||||
|
||||
|
||||
def db_recording_mbid(artist: str, title: str) -> Optional[str]:
|
||||
"""Recording MBID stored on a matched library track (``musicbrainz_recording_id``)."""
|
||||
return _db_match(artist, title)[0]
|
||||
|
||||
|
||||
# Service → the tracks-table column carrying that service's track ID (set by enrichment).
|
||||
# Trusted constants — never user input — so safe to interpolate into the SELECT below.
|
||||
_SERVICE_ID_COLUMNS = {"spotify": "spotify_track_id", "deezer": "deezer_id"}
|
||||
|
||||
|
||||
def db_service_track_id(artist: str, title: str, service: str) -> Optional[str]:
|
||||
"""The service track ID (``spotify_track_id`` / ``deezer_id``) stored on a matched
|
||||
library track — what lets a mirrored playlist be exported BACK to Spotify/Deezer
|
||||
without re-searching, since enrichment already pinned it (#945). Text-matches by
|
||||
(artist, title), same as the MBID resolver. Fail-safe: any miss/error returns None."""
|
||||
column = _SERVICE_ID_COLUMNS.get((service or "").lower())
|
||||
if not column or not title:
|
||||
return None
|
||||
try:
|
||||
from database.music_database import get_database
|
||||
db = get_database()
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
f"SELECT t.{column} FROM tracks t JOIN artists a ON t.artist_id = a.id "
|
||||
"WHERE LOWER(t.title) = LOWER(?) AND LOWER(a.name) = LOWER(?) LIMIT 1",
|
||||
(title, artist),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
val = row[0] if not hasattr(row, "keys") else row[column]
|
||||
return val or None
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug(f"export service-id lookup failed for '{artist} - {title}' ({service}): {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def build_service_resolve_fn(service: str) -> Callable[[str, str], Tuple[Optional[str], Optional[str]]]:
|
||||
"""resolve_fn for service-playlist export: ``(artist, title) -> (service_track_id, 'library')``.
|
||||
Plugs into ``resolve_playlist_tracks(..., id_key='service_track_id')`` exactly like the
|
||||
MBID resolver plugs in for ListenBrainz."""
|
||||
def resolve_fn(artist: str, title: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
tid = db_service_track_id(artist, title, service)
|
||||
return (tid, "library" if tid else None)
|
||||
return resolve_fn
|
||||
|
||||
|
||||
def service_id_from_extra_data(track: Any, service: str) -> Optional[str]:
|
||||
"""The target-service track ID the DISCOVERY step already resolved for this mirrored
|
||||
track, read from its ``extra_data`` blob (#945 — Boulder: "all 50 are discovered to
|
||||
Deezer already, it's not using any of that"). This is free (no API call) and reliable
|
||||
(it's the same id used to mirror the track).
|
||||
|
||||
Only trusted when the track was discovered ON the export's target service — a
|
||||
Deezer-discovered track carries a Deezer id under ``matched_data['id']``, and its
|
||||
``provider`` is the service name. A ``wing_it_fallback`` provider (the low-confidence
|
||||
guess path) deliberately does NOT match here, so those fall through to the library/
|
||||
none path rather than risk a wrong track in the exported playlist."""
|
||||
raw = track.get("extra_data") if isinstance(track, dict) else None
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(data, dict) or not data.get("discovered"):
|
||||
return None
|
||||
if str(data.get("provider") or "").lower() != str(service or "").lower():
|
||||
return None
|
||||
matched = data.get("matched_data")
|
||||
tid = matched.get("id") if isinstance(matched, dict) else None
|
||||
return str(tid) if tid else None
|
||||
|
||||
|
||||
def _track_field(track: Dict[str, Any], *names: str) -> str:
|
||||
for n in names:
|
||||
v = track.get(n)
|
||||
if v:
|
||||
return str(v)
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_service_track_ids(
|
||||
tracks: List[Dict[str, Any]],
|
||||
service: str,
|
||||
*,
|
||||
db_fn: Optional[Callable[[str, str, str], Optional[str]]] = None,
|
||||
search_id_fn: Optional[Callable[[str, str], Optional[str]]] = None,
|
||||
on_progress: Optional[Callable[[int, int, Dict[str, Any]], None]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve a mirrored playlist's tracks to target-service track IDs for export.
|
||||
|
||||
Waterfall per track: the discovery cache (``extra_data`` — free + already confidently
|
||||
matched) → the library track's stored service id → (only when ``search_id_fn`` is
|
||||
given, i.e. the opt-in backfill toggle) a confident live-search match. A track that
|
||||
clears none of these is reported unmatched (caller skips it — never a guessed/wrong
|
||||
id). Returns ``{"resolved": [{artist, title, album, service_track_id}], "stats":
|
||||
{...}}`` with ``from_cache`` / ``from_library`` / ``from_search`` / ``unmatched``
|
||||
tallies for the status display.
|
||||
"""
|
||||
db_fn = db_fn or db_service_track_id
|
||||
total = len(tracks or [])
|
||||
resolved: List[Dict[str, Any]] = []
|
||||
stats: Dict[str, Any] = {
|
||||
"total": total, "resolved": 0, "unmatched": 0,
|
||||
"from_cache": 0, "from_library": 0, "from_search": 0,
|
||||
}
|
||||
for i, t in enumerate(tracks or []):
|
||||
if not isinstance(t, dict):
|
||||
t = {}
|
||||
artist = _track_field(t, "artist", "artist_name", "creator")
|
||||
title = _track_field(t, "title", "track_name", "name")
|
||||
album = _track_field(t, "album", "album_name", "release_name")
|
||||
|
||||
tid = service_id_from_extra_data(t, service)
|
||||
if tid:
|
||||
stats["from_cache"] += 1
|
||||
else:
|
||||
tid = db_fn(artist, title, service)
|
||||
if tid:
|
||||
stats["from_library"] += 1
|
||||
elif search_id_fn is not None:
|
||||
tid = search_id_fn(artist, title)
|
||||
if tid:
|
||||
stats["from_search"] += 1
|
||||
|
||||
resolved.append({"artist": artist, "title": title, "album": album,
|
||||
"service_track_id": tid or None})
|
||||
stats["resolved" if tid else "unmatched"] += 1
|
||||
if on_progress is not None:
|
||||
try:
|
||||
on_progress(i + 1, total, stats)
|
||||
except Exception: # noqa: S110 — a progress error must never fail the export
|
||||
pass
|
||||
|
||||
return {"resolved": resolved, "stats": stats}
|
||||
|
||||
|
||||
# Confidence floor for backfill, on the score_track scale (~1.5 = exact title + exact
|
||||
# artist, thanks to the 1.5x artist boost). A cover/karaoke (x0.05) or a wrong-artist hit
|
||||
# (no boost, caps ~1.0) can't clear this — so backfill never adds a guessed/wrong version.
|
||||
BACKFILL_MIN_SCORE = 1.2
|
||||
|
||||
|
||||
def search_service_track_id(
|
||||
artist: str,
|
||||
title: str,
|
||||
*,
|
||||
search_fn: Callable[[str], List[Any]],
|
||||
min_score: float = BACKFILL_MIN_SCORE,
|
||||
) -> Optional[str]:
|
||||
"""Confident live-search match for export backfill (#945): search the target service
|
||||
for (artist, title), rerank by relevance, and return the top match's id ONLY if it
|
||||
clears the confidence floor. Below the floor → None: the track is left out of the
|
||||
export rather than risk a wrong/cover/karaoke version (the whole point of backfill is
|
||||
coverage WITHOUT the wrong-track risk). ``search_fn(query) -> List[Track]`` is injected
|
||||
so this is unit-testable without a live service."""
|
||||
if not title:
|
||||
return None
|
||||
from core.metadata.relevance import build_combined_search_query, filter_and_rerank
|
||||
query = build_combined_search_query(title, artist)
|
||||
try:
|
||||
candidates = list(search_fn(query) or [])
|
||||
except Exception as exc:
|
||||
logger.debug(f"export backfill search failed for '{artist} - {title}': {exc}")
|
||||
return None
|
||||
if not candidates:
|
||||
return None
|
||||
ranked = filter_and_rerank(
|
||||
candidates, expected_title=title, expected_artist=artist, min_score=min_score,
|
||||
)
|
||||
if not ranked:
|
||||
return None
|
||||
tid = getattr(ranked[0], "id", None)
|
||||
return str(tid) if tid else None
|
||||
|
||||
|
||||
def file_recording_mbid(artist: str, title: str) -> Optional[str]:
|
||||
"""Recording MBID read from the matched track's file tag (set on import post-processing)."""
|
||||
_mbid, fpath = _db_match(artist, title)
|
||||
if not fpath:
|
||||
return None
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
audio = MutagenFile(fpath)
|
||||
if audio is None or not getattr(audio, "tags", None):
|
||||
return None
|
||||
tags = audio.tags
|
||||
# ID3 UFID (MusicBrainz), Vorbis/MP4 musicbrainz_trackid, etc.
|
||||
for key in ("UFID:http://musicbrainz.org", "musicbrainz_trackid",
|
||||
"MUSICBRAINZ_TRACKID", "----:com.apple.iTunes:MusicBrainz Track Id"):
|
||||
try:
|
||||
val = tags.get(key)
|
||||
except Exception:
|
||||
val = None
|
||||
if not val:
|
||||
continue
|
||||
if hasattr(val, "data"): # ID3 UFID frame
|
||||
val = val.data.decode("utf-8", "ignore")
|
||||
if isinstance(val, (list, tuple)):
|
||||
val = val[0] if val else ""
|
||||
if isinstance(val, bytes):
|
||||
val = val.decode("utf-8", "ignore")
|
||||
val = str(val).strip()
|
||||
if val:
|
||||
return val
|
||||
except Exception as exc:
|
||||
logger.debug(f"export file_recording_mbid failed for {fpath}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
_mb_service = None
|
||||
_mb_service_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_mb_service():
|
||||
"""Shared MusicBrainzService (client + cache + DB), created lazily so importing this
|
||||
module never triggers a DB/network connection on paths that don't export."""
|
||||
global _mb_service
|
||||
if _mb_service is None:
|
||||
with _mb_service_lock:
|
||||
if _mb_service is None:
|
||||
from core.musicbrainz_service import MusicBrainzService
|
||||
from database.music_database import get_database
|
||||
_mb_service = MusicBrainzService(get_database())
|
||||
return _mb_service
|
||||
|
||||
|
||||
def musicbrainz_recording_mbid(artist: str, title: str) -> Optional[str]:
|
||||
"""Live MusicBrainz ``match_recording`` — the rate-limited tail."""
|
||||
if not title:
|
||||
return None
|
||||
try:
|
||||
svc = _get_mb_service()
|
||||
if not svc:
|
||||
return None
|
||||
result = svc.match_recording(title, artist)
|
||||
if result and result.get("mbid"):
|
||||
return result["mbid"]
|
||||
except Exception as exc:
|
||||
logger.debug(f"export musicbrainz_recording_mbid failed for '{artist} - {title}': {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def build_resolve_fn(
|
||||
*,
|
||||
db_fn: Callable[[str, str], Optional[str]] = db_recording_mbid,
|
||||
file_fn: Callable[[str, str], Optional[str]] = file_recording_mbid,
|
||||
mb_fn: Callable[[str, str], Optional[str]] = musicbrainz_recording_mbid,
|
||||
cache_lookup: Optional[Callable[[str], Optional[str]]] = None,
|
||||
cache_record: Optional[Callable[[str, str], bool]] = None,
|
||||
) -> Callable[[str, str], Tuple[Optional[str], Optional[str]]]:
|
||||
"""Assemble the export ``resolve_fn(artist, title) -> (mbid, source_label)``.
|
||||
|
||||
Runs cache -> DB -> file -> MusicBrainz, and writes a fresh (non-cache) hit back to the
|
||||
persistent cache. All sources are injectable so the wiring is unit-testable; defaults
|
||||
use the real cache module.
|
||||
"""
|
||||
if cache_lookup is None or cache_record is None:
|
||||
from core.exports import recording_mbid_cache as _cache
|
||||
cache_lookup = cache_lookup or _cache.lookup
|
||||
cache_record = cache_record or _cache.record
|
||||
|
||||
def resolve_fn(artist: str, title: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
sources = [
|
||||
(SRC_CACHE, lambda a, t: cache_lookup(normalize_key(a, t))),
|
||||
(SRC_DB, db_fn),
|
||||
(SRC_FILE, file_fn),
|
||||
(SRC_MUSICBRAINZ, mb_fn),
|
||||
]
|
||||
mbid, label = resolve_recording_mbid(artist, title, sources)
|
||||
if mbid and label and label != SRC_CACHE:
|
||||
try:
|
||||
cache_record(normalize_key(artist, title), mbid)
|
||||
except Exception: # noqa: S110 — cache write is best-effort
|
||||
pass
|
||||
return (mbid, label)
|
||||
|
||||
return resolve_fn
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_resolve_fn",
|
||||
"db_recording_mbid",
|
||||
"file_recording_mbid",
|
||||
"musicbrainz_recording_mbid",
|
||||
]
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
"""Build a JSPF playlist (ListenBrainz-compatible) from resolved SoulSync tracks.
|
||||
|
||||
ListenBrainz's ``POST /1/playlist/create`` requires JSPF where **every track carries a
|
||||
``identifier`` of ``https://musicbrainz.org/recording/<recording-mbid>``** — text-only
|
||||
entries (title/creator alone) are rejected. So a track can only be exported once we've
|
||||
resolved its MusicBrainz *recording* MBID (see ``mbid_resolver``); tracks without one are
|
||||
dropped here and surfaced to the user as "unmatched".
|
||||
|
||||
Pure + I/O-free: callers pass already-resolved track dicts, this returns the JSPF dict
|
||||
(and a small coverage summary). The same JSPF is used for both the downloadable ``.jspf``
|
||||
file and the direct create-playlist POST, so there's one source of truth for the shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
MB_RECORDING_PREFIX = "https://musicbrainz.org/recording/"
|
||||
|
||||
# A MusicBrainz MBID is a canonical UUID. Validate to avoid emitting garbage identifiers
|
||||
# that LB would reject (or, worse, that silently point nowhere).
|
||||
_UUID_RE = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def is_valid_recording_mbid(mbid: Any) -> bool:
|
||||
"""True when ``mbid`` is a well-formed MusicBrainz UUID."""
|
||||
return bool(mbid) and isinstance(mbid, str) and bool(_UUID_RE.match(mbid.strip()))
|
||||
|
||||
|
||||
def _track_entry(track: Dict[str, Any]) -> Dict[str, Any] | None:
|
||||
"""Build one JSPF track entry, or None if the track has no valid recording MBID."""
|
||||
mbid = (track.get("recording_mbid") or "").strip() if isinstance(track.get("recording_mbid"), str) else ""
|
||||
if not is_valid_recording_mbid(mbid):
|
||||
return None
|
||||
entry: Dict[str, Any] = {"identifier": f"{MB_RECORDING_PREFIX}{mbid}"}
|
||||
# Optional, human-friendly fields — LB ignores them on create but they make the
|
||||
# downloaded .jspf readable and round-trippable.
|
||||
if track.get("title"):
|
||||
entry["title"] = str(track["title"])
|
||||
if track.get("artist"):
|
||||
entry["creator"] = str(track["artist"])
|
||||
if track.get("album"):
|
||||
entry["album"] = str(track["album"])
|
||||
return entry
|
||||
|
||||
|
||||
def build_jspf(
|
||||
title: str,
|
||||
tracks: List[Dict[str, Any]],
|
||||
*,
|
||||
creator: str = "",
|
||||
) -> Tuple[Dict[str, Any], Dict[str, int]]:
|
||||
"""Build a ListenBrainz-compatible JSPF dict from resolved tracks.
|
||||
|
||||
``tracks`` is an ordered list of dicts with ``recording_mbid`` (required to be
|
||||
included), plus optional ``title`` / ``artist`` / ``album``. Tracks without a valid
|
||||
recording MBID are skipped (LB rejects them).
|
||||
|
||||
Returns ``(jspf, summary)`` where ``jspf`` is ``{"playlist": {...}}`` and ``summary``
|
||||
is ``{"total", "included", "skipped"}`` for the coverage display.
|
||||
"""
|
||||
jspf_tracks: List[Dict[str, Any]] = []
|
||||
for t in tracks or []:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
entry = _track_entry(t)
|
||||
if entry is not None:
|
||||
jspf_tracks.append(entry)
|
||||
|
||||
playlist: Dict[str, Any] = {
|
||||
"title": (title or "SoulSync Export").strip() or "SoulSync Export",
|
||||
"track": jspf_tracks,
|
||||
}
|
||||
if creator:
|
||||
playlist["creator"] = str(creator)
|
||||
|
||||
total = sum(1 for t in (tracks or []) if isinstance(t, dict))
|
||||
summary = {
|
||||
"total": total,
|
||||
"included": len(jspf_tracks),
|
||||
"skipped": total - len(jspf_tracks),
|
||||
}
|
||||
return {"playlist": playlist}, summary
|
||||
|
||||
|
||||
__all__ = ["build_jspf", "is_valid_recording_mbid", "MB_RECORDING_PREFIX"]
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
"""Resolve a playlist track's MusicBrainz *recording* MBID, cheapest source first.
|
||||
|
||||
A ListenBrainz playlist export needs each track's recording MBID (``jspf_export``). A
|
||||
SoulSync track can supply it from several places, in increasing cost:
|
||||
|
||||
1. **resolution cache** — a prior (artist,title)->mbid result (persistent; reused across
|
||||
playlists and runs, so the same song never costs twice).
|
||||
2. **library DB** — ``tracks.musicbrainz_recording_id`` (set by the MusicBrainz
|
||||
enrichment worker).
|
||||
3. **file tags** — ``MUSICBRAINZ_RECORDING_ID`` written into the audio file on import
|
||||
post-processing (catches tracks enriched at import but not via the worker).
|
||||
4. **MusicBrainz lookup** — a live ``match_recording(artist, title)`` (rate-limited
|
||||
~1 req/s; the slow tail — only hit when 1–3 miss).
|
||||
|
||||
This module is the **pure waterfall**: the caller passes ordered ``(label, fn)`` sources,
|
||||
each ``fn(artist, title) -> mbid | None``, and ``resolve_recording_mbid`` returns the
|
||||
first valid hit plus its label (for the live status / stats). The actual I/O (DB query,
|
||||
mutagen read, MB request, cache read/write) lives in the export job that wires the real
|
||||
sources — so this stays trivially unit-testable and short-circuits correctly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Callable, List, Optional, Tuple
|
||||
|
||||
# Source labels (also used in the live-status breakdown).
|
||||
SRC_CACHE = "cache"
|
||||
SRC_DB = "db"
|
||||
SRC_FILE = "file"
|
||||
SRC_MUSICBRAINZ = "musicbrainz"
|
||||
SRC_NONE = None
|
||||
|
||||
_UUID_RE = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
|
||||
)
|
||||
|
||||
Source = Tuple[str, Callable[[str, str], Optional[str]]]
|
||||
|
||||
|
||||
def _valid(mbid: Any) -> Optional[str]:
|
||||
"""Return the trimmed MBID if it's a well-formed UUID, else None."""
|
||||
if not isinstance(mbid, str):
|
||||
return None
|
||||
m = mbid.strip()
|
||||
return m if _UUID_RE.match(m) else None
|
||||
|
||||
|
||||
def normalize_key(artist: Any, title: Any) -> str:
|
||||
"""Stable cache key for an (artist, title) pair — lower, punctuation-stripped,
|
||||
whitespace-collapsed — so trivial variations share a cache entry."""
|
||||
def _n(v: Any) -> str:
|
||||
s = re.sub(r"[^\w\s]", "", str(v or "").lower())
|
||||
return re.sub(r"\s+", " ", s).strip()
|
||||
return f"{_n(artist)}␟{_n(title)}"
|
||||
|
||||
|
||||
def resolve_recording_mbid(
|
||||
artist: str,
|
||||
title: str,
|
||||
sources: List[Source],
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Walk ``sources`` in order; return ``(mbid, label)`` of the first that yields a
|
||||
valid recording MBID, or ``(None, None)`` when every source misses.
|
||||
|
||||
Each source is ``(label, fn)`` and ``fn(artist, title)`` returns an MBID or None. A
|
||||
source that raises is treated as a miss (never aborts the waterfall) — so one flaky
|
||||
lookup (e.g. a MusicBrainz timeout) can't fail the whole export. Short-circuits: a
|
||||
later/expensive source isn't called once an earlier one hits.
|
||||
"""
|
||||
for label, fn in sources or []:
|
||||
try:
|
||||
mbid = _valid(fn(artist, title))
|
||||
except Exception:
|
||||
mbid = None
|
||||
if mbid:
|
||||
return (mbid, label)
|
||||
return (None, None)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"resolve_recording_mbid",
|
||||
"normalize_key",
|
||||
"SRC_CACHE",
|
||||
"SRC_DB",
|
||||
"SRC_FILE",
|
||||
"SRC_MUSICBRAINZ",
|
||||
]
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
"""Orchestrate resolving a playlist's tracks to recording MBIDs for export (#903).
|
||||
|
||||
This is the testable heart of the export job: walk the playlist's tracks, resolve each to a
|
||||
MusicBrainz recording MBID via an injected ``resolve_fn`` (which the job wires to the
|
||||
cache -> DB -> file -> MusicBrainz waterfall), dedup repeated songs within the run so they
|
||||
only cost one resolution, build the ordered "pseudo-playlist" of resolved tracks, and tally
|
||||
live stats (resolved / unmatched / per-source / deduped) for the on-card status display.
|
||||
|
||||
Pure: all I/O (DB, file reads, MusicBrainz, cache) is behind ``resolve_fn`` and the optional
|
||||
``on_progress`` callback, so the dedup + accounting logic is unit-testable without any
|
||||
network or database. The returned ``resolved`` list feeds straight into ``jspf_export``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from core.exports.mbid_resolver import normalize_key
|
||||
|
||||
# resolve_fn(artist, title) -> (recording_mbid|None, source_label|None)
|
||||
ResolveFn = Callable[[str, str], Tuple[Optional[str], Optional[str]]]
|
||||
ProgressFn = Callable[[int, int, Dict[str, Any]], None]
|
||||
|
||||
|
||||
def _field(track: Dict[str, Any], *names: str) -> str:
|
||||
"""First non-empty value among ``names`` (handles both playlist + LB-cache shapes)."""
|
||||
for n in names:
|
||||
v = track.get(n)
|
||||
if v:
|
||||
return str(v)
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_playlist_tracks(
|
||||
tracks: List[Dict[str, Any]],
|
||||
resolve_fn: ResolveFn,
|
||||
*,
|
||||
on_progress: Optional[ProgressFn] = None,
|
||||
id_key: str = "recording_mbid",
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve every track to an ID and build the export pseudo-playlist.
|
||||
|
||||
``resolve_fn(artist, title) -> (id, source)`` returns whatever ID the target needs —
|
||||
a MusicBrainz recording MBID for ListenBrainz/JSPF (the default), or a Spotify/Deezer
|
||||
track ID for service export. ``id_key`` names the field that ID lands under in each
|
||||
resolved entry (defaults to ``recording_mbid`` so existing LB/JSPF callers are
|
||||
untouched). The dedup + stats + ordering logic is identical regardless of ID type.
|
||||
|
||||
``tracks`` items may use ``artist``/``artist_name`` and ``title``/``track_name`` and
|
||||
``album``/``album_name`` (both the mirrored-playlist and LB-cache shapes are accepted).
|
||||
|
||||
Returns ``{"resolved": [...], "stats": {...}}`` where each resolved entry is
|
||||
``{artist, title, album, <id_key>}`` (the ID is None when unmatched), in original
|
||||
order, and stats carries ``total, resolved, unmatched, deduped, by_source``.
|
||||
"""
|
||||
total = len(tracks or [])
|
||||
memo: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
|
||||
resolved: List[Dict[str, Any]] = []
|
||||
stats: Dict[str, Any] = {
|
||||
"total": total, "resolved": 0, "unmatched": 0, "deduped": 0, "by_source": {},
|
||||
}
|
||||
|
||||
for i, t in enumerate(tracks or []):
|
||||
if not isinstance(t, dict):
|
||||
t = {}
|
||||
artist = _field(t, "artist", "artist_name", "creator")
|
||||
title = _field(t, "title", "track_name", "name")
|
||||
album = _field(t, "album", "album_name", "release_name")
|
||||
key = normalize_key(artist, title)
|
||||
|
||||
if key in memo:
|
||||
mbid, source = memo[key]
|
||||
stats["deduped"] += 1
|
||||
fresh = False
|
||||
else:
|
||||
mbid, source = resolve_fn(artist, title)
|
||||
memo[key] = (mbid, source)
|
||||
fresh = True
|
||||
|
||||
resolved.append({"artist": artist, "title": title, "album": album, id_key: mbid})
|
||||
|
||||
if mbid:
|
||||
stats["resolved"] += 1
|
||||
if fresh and source:
|
||||
stats["by_source"][source] = stats["by_source"].get(source, 0) + 1
|
||||
else:
|
||||
stats["unmatched"] += 1
|
||||
|
||||
if on_progress is not None:
|
||||
try:
|
||||
on_progress(i + 1, total, stats)
|
||||
except Exception: # noqa: S110 — a progress-display error must never fail the export
|
||||
pass
|
||||
|
||||
return {"resolved": resolved, "stats": stats}
|
||||
|
||||
|
||||
__all__ = ["resolve_playlist_tracks"]
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
"""Persistent (artist,title) -> MusicBrainz recording-MBID cache for playlist export.
|
||||
|
||||
The export waterfall (``core.exports.mbid_resolver``) ends in a live MusicBrainz lookup
|
||||
that's rate-limited to ~1 req/s — the slow tail of exporting a big playlist. Remembering a
|
||||
resolved recording MBID ONCE means the same song never costs a second lookup, across every
|
||||
future export and every playlist it appears in.
|
||||
|
||||
Mirrors ``core.metadata.album_mbid_cache`` exactly: a tiny SQLite table, lazy DB accessor,
|
||||
every function wrapped so any DB error degrades to a cache miss / no-op. If this module
|
||||
breaks, exports still work — they just re-resolve via the live waterfall like a cold cache.
|
||||
Key is the normalized ``track_key`` from ``mbid_resolver.normalize_key(artist, title)``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("exports.recording_mbid_cache")
|
||||
|
||||
_db_factory_lock = threading.Lock()
|
||||
_db_factory = None
|
||||
|
||||
|
||||
def _get_database():
|
||||
"""Resolve the MusicDatabase singleton lazily; None on any failure (treated as miss)."""
|
||||
global _db_factory
|
||||
with _db_factory_lock:
|
||||
if _db_factory is None:
|
||||
try:
|
||||
from database.music_database import get_database
|
||||
_db_factory = get_database
|
||||
except Exception as exc:
|
||||
logger.warning(f"Recording-MBID cache: could not load database module: {exc}")
|
||||
return None
|
||||
try:
|
||||
return _db_factory()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Recording-MBID cache: database accessor failed: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def lookup(track_key: str) -> Optional[str]:
|
||||
"""Read a cached recording MBID for ``track_key``; None on miss or any DB error."""
|
||||
if not track_key:
|
||||
return None
|
||||
db = _get_database()
|
||||
if db is None:
|
||||
return None
|
||||
conn = None
|
||||
try:
|
||||
conn = db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT recording_mbid FROM mb_recording_cache WHERE track_key = ? LIMIT 1",
|
||||
(track_key,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return (row[0] if not hasattr(row, "keys") else row["recording_mbid"]) or None
|
||||
except Exception as exc:
|
||||
logger.debug(f"Recording-MBID cache lookup failed: {exc}")
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: S110 — finally cleanup
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def record(track_key: str, recording_mbid: str) -> bool:
|
||||
"""Persist ``track_key`` -> ``recording_mbid`` (idempotent). False on any failure."""
|
||||
if not track_key or not recording_mbid:
|
||||
return False
|
||||
db = _get_database()
|
||||
if db is None:
|
||||
return False
|
||||
conn = None
|
||||
try:
|
||||
conn = db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT OR REPLACE INTO mb_recording_cache "
|
||||
"(track_key, recording_mbid, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
(track_key, recording_mbid),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug(f"Recording-MBID cache record failed: {exc}")
|
||||
return False
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: S110 — finally cleanup
|
||||
pass
|
||||
|
||||
|
||||
def clear_all() -> bool:
|
||||
"""Wipe the cache (tests / forced re-resolve)."""
|
||||
db = _get_database()
|
||||
if db is None:
|
||||
return False
|
||||
conn = None
|
||||
try:
|
||||
conn = db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM mb_recording_cache")
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(f"Recording-MBID cache clear failed: {exc}")
|
||||
return False
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: S110 — finally cleanup
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["lookup", "record", "clear_all"]
|
||||
|
|
@ -16,24 +16,8 @@ _rate_limit_backoff = 0 # Extra backoff seconds after 429
|
|||
_rate_limit_until = 0 # Timestamp until which all calls should wait
|
||||
|
||||
|
||||
class GeniusRateLimitedError(requests.exceptions.RequestException):
|
||||
"""Raised IMMEDIATELY while Genius is inside a 429 backoff window.
|
||||
|
||||
Subclasses RequestException so every existing caller (the import
|
||||
pipeline's source lookups, the enrichment worker's per-item guards)
|
||||
already treats it as a plain network failure: log one line, skip
|
||||
Genius, move on. Lyrics/metadata garnish — nothing is allowed to WAIT
|
||||
for it."""
|
||||
|
||||
|
||||
def rate_limited(func):
|
||||
"""Decorator to enforce rate limiting on Genius API calls.
|
||||
|
||||
The 429 backoff is a fail-fast GATE, not a sleep. The old version
|
||||
slept the backoff in the calling thread — while HOLDING the API lock,
|
||||
so every other Genius caller queued behind it — and then re-raised
|
||||
anyway. The import pipeline measurably napped 2x120s per track
|
||||
("Genius track lookup took 242.4s") for lookups that still failed."""
|
||||
"""Decorator to enforce rate limiting on Genius API calls with exponential backoff on 429"""
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
global _last_api_call_time, _rate_limit_backoff, _rate_limit_until
|
||||
|
|
@ -41,12 +25,11 @@ def rate_limited(func):
|
|||
with _api_call_lock:
|
||||
current_time = time.time()
|
||||
|
||||
# Inside a backoff window: fail fast, never wait.
|
||||
# If in backoff period from a previous 429, wait it out
|
||||
if current_time < _rate_limit_until:
|
||||
remaining = _rate_limit_until - current_time
|
||||
raise GeniusRateLimitedError(
|
||||
f"Genius in 429 backoff for another {remaining:.0f}s — skipping"
|
||||
)
|
||||
wait = _rate_limit_until - current_time
|
||||
logger.debug(f"Genius rate limit backoff: waiting {wait:.1f}s")
|
||||
time.sleep(wait)
|
||||
|
||||
time_since_last_call = time.time() - _last_api_call_time
|
||||
if time_since_last_call < MIN_API_INTERVAL:
|
||||
|
|
@ -65,11 +48,11 @@ def rate_limited(func):
|
|||
return result
|
||||
except Exception as e:
|
||||
if "429" in str(e) or "rate limit" in str(e).lower():
|
||||
# Open the gate: 30s → 60s → 120s (cap). Callers fail fast
|
||||
# against it instead of sleeping here.
|
||||
# Exponential backoff: 30s → 60s → 120s (cap at 120s)
|
||||
_rate_limit_backoff = min(120, max(30, _rate_limit_backoff * 2) if _rate_limit_backoff else 30)
|
||||
_rate_limit_until = time.time() + _rate_limit_backoff
|
||||
logger.warning(f"Genius 429 rate limit — gating calls for {_rate_limit_backoff}s")
|
||||
logger.warning(f"Genius 429 rate limit — backing off {_rate_limit_backoff}s")
|
||||
time.sleep(_rate_limit_backoff)
|
||||
raise e
|
||||
return wrapper
|
||||
|
||||
|
|
|
|||
|
|
@ -178,16 +178,6 @@ class GeniusWorker:
|
|||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Pinned-group override (Manage Enrichment Workers): process one
|
||||
# entity type first, then fall through to the normal chain. Genius
|
||||
# is artist/track only, so albums are not honored.
|
||||
from core.worker_utils import read_enrichment_priority, priority_pending_item
|
||||
_prio = read_enrichment_priority('genius')
|
||||
if _prio in ('artist', 'track'):
|
||||
_pi = priority_pending_item(cursor, 'genius', _prio)
|
||||
if _pi:
|
||||
return _pi
|
||||
|
||||
# Priority 1: Unattempted artists
|
||||
cursor.execute("""
|
||||
SELECT id, name
|
||||
|
|
|
|||
|
|
@ -36,34 +36,9 @@ import requests as http_requests
|
|||
from utils.logging_config import get_logger
|
||||
from config.settings import config_manager
|
||||
from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus
|
||||
from core.quality.source_map import quality_from_tidal_tier, quality_tier_for_source
|
||||
|
||||
logger = get_logger("hifi_client")
|
||||
|
||||
# A media playlist whose total runtime is below this fraction of the track's
|
||||
# real duration is a preview (some Monochrome instances only have 30s Tidal
|
||||
# DOWNLOAD access — a 220s track comes back as ~30s of segments + ENDLIST).
|
||||
_PREVIEW_DURATION_RATIO = 0.85
|
||||
_EXTINF_RE = re.compile(r'#EXTINF:\s*([0-9.]+)')
|
||||
|
||||
|
||||
def hls_total_seconds(playlist_text: str) -> float:
|
||||
"""Sum the ``#EXTINF`` segment durations in an HLS media playlist."""
|
||||
return sum(float(x) for x in _EXTINF_RE.findall(playlist_text or ''))
|
||||
|
||||
|
||||
def is_preview_playlist(playlist_s: float, track_s: float,
|
||||
ratio: float = _PREVIEW_DURATION_RATIO) -> bool:
|
||||
"""True when the playlist runtime is far shorter than the track's real
|
||||
duration (a preview). Returns False when either duration is unknown, so a
|
||||
missing reference never false-positives — the post-download audio guard is
|
||||
the safety net.
|
||||
"""
|
||||
if not playlist_s or not track_s or track_s <= 0:
|
||||
return False
|
||||
return playlist_s < track_s * ratio
|
||||
|
||||
|
||||
# HLS quality presets mapping to /trackManifests/ format parameters
|
||||
HLS_QUALITY_MAP = {
|
||||
'hires': {
|
||||
|
|
@ -117,132 +92,8 @@ DEFAULT_INSTANCES = [
|
|||
'https://hund.qqdl.site',
|
||||
'https://katze.qqdl.site',
|
||||
'https://arran.monochrome.tf',
|
||||
'https://us-west.monochrome.tf', # community-confirmed working (Sokhi)
|
||||
]
|
||||
|
||||
# The default instances as they shipped BEFORE the auto-push mechanism below.
|
||||
# Used as the one-time baseline for the "already offered" set so existing
|
||||
# installs don't get pre-existing defaults they'd deliberately removed
|
||||
# resurrected — only genuinely NEW defaults are pushed.
|
||||
LEGACY_DEFAULTS = [
|
||||
'https://triton.squid.wtf',
|
||||
'https://hifi-one.spotisaver.net',
|
||||
'https://hifi-two.spotisaver.net',
|
||||
'https://hund.qqdl.site',
|
||||
'https://katze.qqdl.site',
|
||||
'https://arran.monochrome.tf',
|
||||
]
|
||||
|
||||
|
||||
def compute_new_default_pushes(all_defaults, offered, legacy_baseline, existing):
|
||||
"""Decide which default instances to auto-add to an EXISTING install.
|
||||
|
||||
A new working instance added to ``DEFAULT_INSTANCES`` should reach everyone,
|
||||
not just fresh installs / people who click "Restore Defaults" — but we must
|
||||
NOT re-add defaults a user deliberately removed.
|
||||
|
||||
The ``offered`` set records every default ever presented to this install.
|
||||
First run (``offered is None``) baselines to ``legacy_baseline`` (the defaults
|
||||
that shipped before tracking), so those are treated as already-offered. Any
|
||||
default NOT in the offered set is genuinely new → added once (unless already
|
||||
present) and recorded.
|
||||
|
||||
Pure: returns ``(urls_to_add, new_offered_list)``. The caller does the I/O.
|
||||
"""
|
||||
def _n(u):
|
||||
return (u or '').rstrip('/')
|
||||
base = list(legacy_baseline) if offered is None else list(offered)
|
||||
offered_set = {_n(u) for u in base}
|
||||
existing_set = {_n(u) for u in (existing or [])}
|
||||
to_add, new_offered = [], list(base)
|
||||
for u in all_defaults:
|
||||
if _n(u) in offered_set:
|
||||
continue
|
||||
offered_set.add(_n(u))
|
||||
new_offered.append(u)
|
||||
if _n(u) not in existing_set:
|
||||
to_add.append(u)
|
||||
return to_add, new_offered
|
||||
|
||||
|
||||
_EXTINF_RE = re.compile(r'#EXTINF:\s*([0-9]+(?:\.[0-9]+)?)')
|
||||
|
||||
|
||||
def sum_hls_segment_seconds(playlist_text: str) -> float:
|
||||
"""Total audio seconds an HLS media playlist actually provides — the sum of its
|
||||
``#EXTINF`` segment durations. This is the authoritative "how much audio is really
|
||||
here" signal: a PREVIEW manifest serves only ~30s of segments even though the track
|
||||
is full-length, so summing EXTINF catches it before we waste the download. Returns
|
||||
0.0 when the playlist has no EXTINF lines (master playlists, legacy manifests) — the
|
||||
caller treats 0 as 'unknown', never as 'preview'."""
|
||||
total = 0.0
|
||||
for m in _EXTINF_RE.finditer(playlist_text or ''):
|
||||
try:
|
||||
total += float(m.group(1))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return total
|
||||
|
||||
|
||||
def is_short_audio(actual_seconds: float, expected_seconds: float, threshold: float = 0.8) -> bool:
|
||||
"""True when ``actual`` is meaningfully shorter than ``expected`` — i.e. a preview
|
||||
clip or a truncated/corrupt download. Conservative: returns False whenever either
|
||||
value is missing/zero (unknown ⇒ never reject), and only trips below ``threshold``
|
||||
of the expected length (previews are ~15% of full, so the margin is huge)."""
|
||||
try:
|
||||
a, e = float(actual_seconds or 0), float(expected_seconds or 0)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if a <= 0 or e <= 0:
|
||||
return False
|
||||
return a < e * threshold
|
||||
|
||||
|
||||
def is_fake_lossless_bitrate(size_bytes, claimed_seconds, sample_rate, bits_per_sample,
|
||||
channels, min_ratio: float = 0.30) -> bool:
|
||||
"""True when a 'lossless' file's data is FAR too small for its claimed length — the
|
||||
fingerprint of a ~30s preview whose STREAMINFO/container was faked to the full
|
||||
duration (so every length header reads 'full' and only the bitrate gives it away).
|
||||
Real FLAC is ~40-75% of raw PCM; a preview padded to full length implies single-digit
|
||||
%. Conservative: 0 / bad inputs return False (never reject on unknowns)."""
|
||||
try:
|
||||
sz, secs = float(size_bytes or 0), float(claimed_seconds or 0)
|
||||
sr, bits, ch = int(sample_rate or 0), int(bits_per_sample or 0), int(channels or 0)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if sz <= 0 or secs <= 0 or sr <= 0 or bits <= 0 or ch <= 0:
|
||||
return False
|
||||
return (sz * 8 / secs) < (sr * bits * ch) * min_ratio
|
||||
|
||||
|
||||
def parse_ffmpeg_time(stderr_text) -> float:
|
||||
"""The last ``time=HH:MM:SS.xx`` ffmpeg prints while decoding — the REAL decoded
|
||||
length (immune to a faked container/STREAMINFO duration). 0.0 if not found."""
|
||||
last = 0.0
|
||||
for m in re.finditer(r'time=(\d+):(\d+):(\d+(?:\.\d+)?)', stderr_text or ''):
|
||||
last = int(m.group(1)) * 3600 + int(m.group(2)) * 60 + float(m.group(3))
|
||||
return last
|
||||
|
||||
|
||||
def is_preview_download(real_seconds, reference_seconds, *, is_lossless, size_bytes,
|
||||
sample_rate, bits_per_sample, channels):
|
||||
"""Is a finished file a preview/truncated fake? Two independent signals, so it fires
|
||||
even when the fakery declares full length at every layer:
|
||||
1. DECODED length far below the reference (the ground truth, when a decoder ran);
|
||||
2. for lossless, an impossibly-low implied bitrate (no decoder needed).
|
||||
Returns ``(is_fake, reason)``."""
|
||||
if real_seconds and is_short_audio(real_seconds, reference_seconds):
|
||||
return True, "decoded %.0fs of %.0fs" % (real_seconds, reference_seconds)
|
||||
if is_lossless and is_fake_lossless_bitrate(size_bytes, reference_seconds, sample_rate,
|
||||
bits_per_sample, channels):
|
||||
kbps = (float(size_bytes) * 8 / reference_seconds / 1000) if reference_seconds else 0
|
||||
return True, "%.0fkbps lossless over %.0fs (far too low — a ~30s preview)" % (kbps, reference_seconds)
|
||||
return False, ""
|
||||
|
||||
|
||||
# Run the new-default push at most once per process.
|
||||
_pushed_new_defaults = False
|
||||
|
||||
|
||||
from core.download_plugins.base import DownloadSourcePlugin
|
||||
|
||||
|
|
@ -257,10 +108,7 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
if download_path is None:
|
||||
download_path = config_manager.get('soulseek.download_path', './downloads')
|
||||
self.download_path = Path(download_path)
|
||||
try:
|
||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
logger.warning(f"Could not verify download path {self.download_path}: {e}")
|
||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._instances = []
|
||||
self._instance_lock = threading.Lock()
|
||||
|
|
@ -290,41 +138,11 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
def set_engine(self, engine):
|
||||
self._engine = engine
|
||||
|
||||
def _push_new_default_instances(self, db):
|
||||
"""One-time-per-process: auto-add any genuinely-new default instances to an
|
||||
existing config so a newly-added working instance reaches everyone, not
|
||||
just fresh installs / Restore-Defaults clickers. Never resurrects defaults
|
||||
a user removed (tracked via the persisted 'offered' set)."""
|
||||
global _pushed_new_defaults
|
||||
if _pushed_new_defaults:
|
||||
return
|
||||
try:
|
||||
from config.settings import config_manager
|
||||
offered = config_manager.get('hifi.offered_defaults', None)
|
||||
existing = db.get_all_hifi_instances()
|
||||
to_add, new_offered = compute_new_default_pushes(
|
||||
DEFAULT_INSTANCES, offered, LEGACY_DEFAULTS,
|
||||
[i.get('url') for i in existing],
|
||||
)
|
||||
if to_add:
|
||||
priority = len(existing)
|
||||
for url in to_add:
|
||||
if db.add_hifi_instance(url.rstrip('/'), priority):
|
||||
priority += 1
|
||||
logger.info(f"[HiFi] Auto-added {len(to_add)} new default instance(s) "
|
||||
f"to existing config: {to_add}")
|
||||
if offered is None or to_add:
|
||||
config_manager.set('hifi.offered_defaults', new_offered)
|
||||
_pushed_new_defaults = True
|
||||
except Exception as e:
|
||||
logger.warning(f"[HiFi] new-default auto-push skipped: {e}")
|
||||
|
||||
def _load_instances_from_db(self):
|
||||
try:
|
||||
from database.music_database import get_database
|
||||
db = get_database()
|
||||
db.seed_hifi_instances(DEFAULT_INSTANCES)
|
||||
self._push_new_default_instances(db)
|
||||
rows = db.get_hifi_instances()
|
||||
urls = [r['url'] for r in rows if r['enabled']]
|
||||
if urls:
|
||||
|
|
@ -725,8 +543,7 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
|
||||
return init_uri, segment_uris
|
||||
|
||||
def _get_hls_manifest(self, track_id: int, quality: str = 'lossless',
|
||||
expected_duration_s: float = 0) -> Optional[Dict]:
|
||||
def _get_hls_manifest(self, track_id: int, quality: str = 'lossless') -> Optional[Dict]:
|
||||
q_info = HLS_QUALITY_MAP.get(quality, HLS_QUALITY_MAP['lossless'])
|
||||
formats = q_info['formats']
|
||||
|
||||
|
|
@ -766,7 +583,6 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
logger.warning(f"Failed to parse HLS playlist for track {track_id}: {e}")
|
||||
return None
|
||||
|
||||
media_text = playlist_text # the playlist that actually carries the EXTINF segments
|
||||
if '#EXT-X-STREAM-INF' in playlist_text and segment_uris:
|
||||
playlist_uri = segment_uris[0]
|
||||
try:
|
||||
|
|
@ -774,26 +590,11 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
variant_resp = self.session.get(playlist_uri, allow_redirects=True, timeout=30)
|
||||
variant_resp.raise_for_status()
|
||||
variant_text = variant_resp.text
|
||||
media_text = variant_text
|
||||
init_uri, segment_uris = self._parse_hls_playlist(variant_text, playlist_uri)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch variant playlist for track {track_id}: {e}")
|
||||
return None
|
||||
|
||||
# Preview detection — some instances only have 30s Tidal DOWNLOAD
|
||||
# access, returning a playlist far shorter than the real track. Decline
|
||||
# it (and rotate the instance) so the orchestrator falls through to a
|
||||
# real source instead of fetching a 30s file that gets quarantined.
|
||||
playlist_s = hls_total_seconds(media_text)
|
||||
if is_preview_playlist(playlist_s, expected_duration_s):
|
||||
logger.warning(
|
||||
f"HiFi manifest for track {track_id} ({quality}) is a "
|
||||
f"{playlist_s:.0f}s preview of a {expected_duration_s:.0f}s track — "
|
||||
f"declining this instance"
|
||||
)
|
||||
self._rotate_instance(self._current_instance)
|
||||
return None
|
||||
|
||||
if init_uri:
|
||||
logger.info(f"HiFi HLS manifest for track {track_id}: "
|
||||
f"init segment + {len(segment_uris)} segments ({quality})")
|
||||
|
|
@ -807,9 +608,6 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
'extension': q_info['extension'],
|
||||
'codec': q_info['codec'],
|
||||
'quality': quality,
|
||||
# Real audio length the manifest provides (sum of EXTINF) — used to reject
|
||||
# preview manifests before downloading. 0.0 = unknown (don't reject).
|
||||
'manifest_duration': sum_hls_segment_seconds(media_text),
|
||||
}
|
||||
|
||||
def _get_legacy_track_manifest(self, track_id: int, quality: str = 'lossless') -> Optional[Dict]:
|
||||
|
|
@ -834,57 +632,15 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
'quality': quality,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _probe_audio_seconds(path) -> float:
|
||||
"""Real decoded audio length of a finished file, via mutagen (already a dep).
|
||||
0.0 on any failure — the caller treats 0 as 'unknown' and never rejects on it."""
|
||||
try:
|
||||
from mutagen import File as _MutagenFile
|
||||
mf = _MutagenFile(str(path))
|
||||
info = getattr(mf, 'info', None) if mf is not None else None
|
||||
if info is not None:
|
||||
return float(getattr(info, 'length', 0) or 0)
|
||||
except Exception as _probe_err: # noqa: BLE001
|
||||
logger.debug("mutagen audio-length probe failed for %s: %s", path, _probe_err)
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _find_ffmpeg():
|
||||
ff = shutil.which('ffmpeg')
|
||||
if ff:
|
||||
return ff
|
||||
cand = Path(__file__).parent.parent / 'tools' / ('ffmpeg.exe' if os.name == 'nt' else 'ffmpeg')
|
||||
return str(cand) if cand.exists() else None
|
||||
|
||||
def _probe_real_seconds(self, path) -> float:
|
||||
"""REAL decoded audio length via ffmpeg — decodes the actual frames, so it sees
|
||||
through a faked STREAMINFO/container duration (a 30s preview claiming full
|
||||
length decodes to 30s). 0.0 if ffmpeg is unavailable or on error."""
|
||||
ff = self._find_ffmpeg()
|
||||
if not ff:
|
||||
return 0.0
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ff, '-hide_banner', '-nostdin', '-i', str(path), '-map', '0:a:0', '-f', 'null', '-'],
|
||||
capture_output=True, text=True, timeout=180)
|
||||
return parse_ffmpeg_time(proc.stderr)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _flac_props(path):
|
||||
"""(sample_rate, bits_per_sample, channels) for the bitrate sanity check, or None."""
|
||||
try:
|
||||
from mutagen.flac import FLAC
|
||||
si = FLAC(str(path)).info
|
||||
return (si.sample_rate, si.bits_per_sample, si.channels)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _demux_flac(self, input_path: Path, output_path: Path) -> None:
|
||||
ffmpeg = self._find_ffmpeg()
|
||||
ffmpeg = shutil.which('ffmpeg')
|
||||
if not ffmpeg:
|
||||
raise RuntimeError('ffmpeg is required to demux FLAC from MP4. Install ffmpeg and retry.')
|
||||
tools_dir = Path(__file__).parent.parent / 'tools'
|
||||
ffmpeg_candidate = tools_dir / ('ffmpeg.exe' if os.name == 'nt' else 'ffmpeg')
|
||||
if ffmpeg_candidate.exists():
|
||||
ffmpeg = str(ffmpeg_candidate)
|
||||
else:
|
||||
raise RuntimeError('ffmpeg is required to demux FLAC from MP4. Install ffmpeg and retry.')
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
|
|
@ -916,18 +672,13 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
loop = asyncio.get_event_loop()
|
||||
tracks = await loop.run_in_executor(None, lambda: self.search_raw(query))
|
||||
|
||||
quality_key = quality_tier_for_source('hifi', default='lossless')
|
||||
quality_key = config_manager.get('hifi_download.quality', 'lossless')
|
||||
q_info = HLS_QUALITY_MAP.get(quality_key, HLS_QUALITY_MAP['lossless'])
|
||||
|
||||
# HiFi is Tidal-backed; stamp the configured tier so the global
|
||||
# ranker sees real sample_rate/bit_depth, not just 'flac'.
|
||||
tier_quality = quality_from_tidal_tier(quality_key)
|
||||
|
||||
results = []
|
||||
for t in tracks:
|
||||
try:
|
||||
tr = self._to_track_result(t, q_info)
|
||||
tr.set_quality(tier_quality)
|
||||
results.append(tr)
|
||||
except Exception as e:
|
||||
logger.debug(f"Skipping track result conversion: {e}")
|
||||
|
|
@ -998,7 +749,7 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
)
|
||||
|
||||
def _download_sync(self, download_id: str, track_id: int, display_name: str) -> Optional[str]:
|
||||
quality_key = quality_tier_for_source('hifi', default='lossless')
|
||||
quality_key = config_manager.get('hifi_download.quality', 'lossless')
|
||||
chain = ['hires', 'lossless', 'high', 'low']
|
||||
start = chain.index(quality_key) if quality_key in chain else 1
|
||||
allow_fallback = config_manager.get('hifi_download.allow_fallback', True)
|
||||
|
|
@ -1006,26 +757,12 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
|
||||
MIN_AUDIO_SIZE = 100 * 1024
|
||||
|
||||
# Expected track length, drives every preview/truncation guard here:
|
||||
# * _get_hls_manifest's pre-download is_preview_playlist check
|
||||
# * the pre-download is_short_audio manifest check
|
||||
# * the post-download is_preview_download faked-header decode check
|
||||
# Best-effort: a 0 here just disables the duration checks, never rejects.
|
||||
expected_s = 0.0
|
||||
try:
|
||||
info = self.get_track_info(track_id) or {}
|
||||
expected_s = float(info.get('duration_s') or 0)
|
||||
except Exception:
|
||||
expected_s = 0.0
|
||||
expected_duration_s = expected_s # alias for _get_hls_manifest's param name
|
||||
|
||||
for q_key in chain:
|
||||
if self.shutdown_check and self.shutdown_check():
|
||||
logger.info("Shutdown detected, aborting HiFi download")
|
||||
return None
|
||||
|
||||
manifest_info = self._get_hls_manifest(track_id, quality=q_key,
|
||||
expected_duration_s=expected_duration_s)
|
||||
manifest_info = self._get_hls_manifest(track_id, quality=q_key)
|
||||
if (
|
||||
not manifest_info
|
||||
or (
|
||||
|
|
@ -1036,18 +773,6 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
logger.warning(f"No HLS manifest at quality {q_key}, trying next")
|
||||
continue
|
||||
|
||||
# Preview guard #1 (pre-download): a preview manifest serves only ~30s of
|
||||
# segments for a full-length track. A preview means THIS SOURCE only has a
|
||||
# preview of the track — lower quality tiers are the SAME preview — so abort
|
||||
# HiFi entirely and let the orchestrator fall through to the next SOURCE
|
||||
# (soulseek/youtube/…), rather than landing a lower-tier preview.
|
||||
manifest_s = float(manifest_info.get('manifest_duration') or 0)
|
||||
if is_short_audio(manifest_s, expected_s):
|
||||
logger.warning(
|
||||
"HiFi has only a PREVIEW of '%s' (manifest %.0fs of %.0fs at %s) — "
|
||||
"failing HiFi so the next source is tried", display_name, manifest_s, expected_s, q_key)
|
||||
return None
|
||||
|
||||
extension = manifest_info['extension']
|
||||
safe_name = re.sub(r'[<>:"/\\|?*]', '_', display_name)
|
||||
out_filename = f"{safe_name}.{extension}"
|
||||
|
|
@ -1127,31 +852,6 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
out_path.unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
# Preview guard #2 (post-download): the real catch. HiFi previews fake the
|
||||
# FULL length in every header — manifest EXTINF, m4a moov, FLAC
|
||||
# total_samples — so only the DECODED audio (or, for lossless, the
|
||||
# bitrate) reveals the ~30s truth. Reference = the largest length any
|
||||
# header claims (so the file's own faked claim becomes the bar its real
|
||||
# audio must clear); is_preview_download decodes + bitrate-checks.
|
||||
ref_s = max(expected_s, self._probe_audio_seconds(out_path))
|
||||
real_s = self._probe_real_seconds(out_path)
|
||||
props = self._flac_props(out_path) if is_flac else None
|
||||
fake, why = is_preview_download(
|
||||
real_s, ref_s, is_lossless=is_flac, size_bytes=final_size,
|
||||
sample_rate=(props[0] if props else 0),
|
||||
bits_per_sample=(props[1] if props else 0),
|
||||
channels=(props[2] if props else 0))
|
||||
if fake:
|
||||
# A preview at this tier means the SOURCE only has a preview — every
|
||||
# lower tier is the same 30s clip (and the lossy ones dodge the
|
||||
# bitrate check). Abort HiFi so the orchestrator tries the next
|
||||
# SOURCE, instead of cascading down into an accepted lower-tier preview.
|
||||
logger.warning(
|
||||
"HiFi has only a PREVIEW of '%s' (%s at %s) — failing HiFi so the "
|
||||
"next source is tried", display_name, why, q_key)
|
||||
out_path.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
logger.info(f"HiFi download complete ({q_key}): {out_path} "
|
||||
f"({final_size / (1024*1024):.1f} MB)")
|
||||
return str(out_path)
|
||||
|
|
|
|||
|
|
@ -172,14 +172,11 @@ class ImageCache:
|
|||
raise ImageCacheError(f"Upstream response is not an image: {mime_type}")
|
||||
|
||||
declared_size = response.headers.get("Content-Length")
|
||||
expected_bytes = None
|
||||
try:
|
||||
if declared_size:
|
||||
expected_bytes = int(declared_size)
|
||||
if expected_bytes > self.max_download_bytes:
|
||||
raise ImageCacheError("Image exceeds configured size limit")
|
||||
if declared_size and int(declared_size) > self.max_download_bytes:
|
||||
raise ImageCacheError("Image exceeds configured size limit")
|
||||
except ValueError:
|
||||
expected_bytes = None
|
||||
pass
|
||||
|
||||
ext = mimetypes.guess_extension(mime_type) or ".img"
|
||||
if ext == ".jpe":
|
||||
|
|
@ -208,22 +205,6 @@ class ImageCache:
|
|||
if total <= 0:
|
||||
raise ImageCacheError("Image response was empty")
|
||||
|
||||
# Truncation guard (#750): a dropped/short connection makes
|
||||
# iter_content end early WITHOUT raising, so a partial image would
|
||||
# otherwise be committed as status='ok' and cached permanently —
|
||||
# rendering as a half-decoded cover (top strip, rest grey). If the
|
||||
# server declared a Content-Length and we got fewer bytes, treat it
|
||||
# as a failed download: discard the tmp file and don't cache it, so
|
||||
# the next request retries fresh instead of serving a broken file.
|
||||
if expected_bytes is not None and total < expected_bytes:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except Exception as cleanup_exc:
|
||||
logger.debug("image_cache tmp cleanup failed: %s", cleanup_exc)
|
||||
raise ImageCacheError(
|
||||
f"Truncated image download: got {total} of {expected_bytes} bytes"
|
||||
)
|
||||
|
||||
os.replace(tmp_path, path)
|
||||
expires_at = now + self.ttl_seconds
|
||||
with self._db_lock:
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
"""Canonical album grouping for the SoulSync standalone import.
|
||||
|
||||
SoulSync grouped imported tracks into albums by the album NAME string
|
||||
(``_stable_soulsync_id("artist::album_name")``). That splits one release into
|
||||
several album rows whenever the name string drifts between imports (case,
|
||||
punctuation, ``(Deluxe Edition)`` suffixes, source-A-vs-B spelling), and every
|
||||
downstream tool (Library Re-tag, Cover-Art Filler) then dresses each split row
|
||||
in its own cover — so songs that belong to one album end up with different art
|
||||
(Sokhi).
|
||||
|
||||
This module is the pure, seam-testable heart of "group by canonical id, not
|
||||
name": when an imported track carries a metadata-source RELEASE id, prefer
|
||||
matching an existing album row by that id over the fragile name string, so the
|
||||
SAME release always lands in ONE album row regardless of how its name was typed.
|
||||
|
||||
Scope (deliberate): this unifies differently-named imports of the SAME release.
|
||||
It does NOT merge a track that genuinely matched a SINGLE release (a different
|
||||
release id) into its parent album — that needs single->album resolution upstream
|
||||
and is a separate change. New imports only; existing rows are left untouched.
|
||||
|
||||
Pure SQL-over-a-cursor; no app singletons, so it tests against an in-memory DB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("imports.album_grouping")
|
||||
|
||||
# Album source-id columns this grouping may key on. An allowlist (not arbitrary
|
||||
# interpolation) — the column name IS spliced into SQL, so it must be a known,
|
||||
# trusted identifier. Mirrors get_library_source_id_columns()' 'album' values.
|
||||
ALLOWED_ALBUM_SOURCE_COLS = frozenset({
|
||||
"spotify_album_id",
|
||||
"itunes_album_id",
|
||||
"deezer_id",
|
||||
"soul_id",
|
||||
"discogs_id",
|
||||
"musicbrainz_release_id",
|
||||
})
|
||||
|
||||
|
||||
def find_existing_soulsync_album_id(
|
||||
cursor: Any,
|
||||
*,
|
||||
name_key_id: str,
|
||||
artist_id: str,
|
||||
album_name: str,
|
||||
album_source_col: Optional[str] = None,
|
||||
album_source_id: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve the existing ``soulsync`` album row a track should join, or None
|
||||
(caller inserts a new row keyed by ``name_key_id``).
|
||||
|
||||
Match precedence:
|
||||
1. ``name_key_id`` — the exact prior stable-name-hash id (unchanged
|
||||
behaviour: a re-import with the identical name hits its own row).
|
||||
2. ``album_source_col == album_source_id`` — CANONICAL grouping: an
|
||||
existing row already carrying THIS release's source id, so a
|
||||
differently-named import of the same release unifies instead of
|
||||
splitting. Only when the column is allow-listed and the id is non-empty.
|
||||
3. ``(title, artist_id)`` — the legacy name match (kept so nothing that
|
||||
grouped before stops grouping now).
|
||||
"""
|
||||
cursor.execute(
|
||||
"SELECT id FROM albums WHERE id = ? AND server_source = 'soulsync'",
|
||||
(name_key_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
|
||||
if album_source_col in ALLOWED_ALBUM_SOURCE_COLS and album_source_id:
|
||||
try:
|
||||
cursor.execute(
|
||||
f"SELECT id FROM albums WHERE {album_source_col} = ? "
|
||||
"AND server_source = 'soulsync' LIMIT 1",
|
||||
(album_source_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
except Exception as exc:
|
||||
# That source has no dedicated album column on this DB (e.g. Deezer
|
||||
# doesn't split per-entity id columns) — fall through to the name
|
||||
# match rather than break the import. Mirrors the guarded source-id
|
||||
# UPDATE the caller already does on insert.
|
||||
logger.debug("album source-id lookup skipped (%s): %s", album_source_col, exc)
|
||||
|
||||
cursor.execute(
|
||||
"SELECT id FROM albums WHERE title COLLATE NOCASE = ? AND artist_id = ? "
|
||||
"AND server_source = 'soulsync' LIMIT 1",
|
||||
(album_name, artist_id),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return row[0] if row else None
|
||||
|
|
@ -156,11 +156,8 @@ def score_file_against_track(
|
|||
score = 0.0
|
||||
|
||||
# Title similarity (TITLE_WEIGHT). Falls back to filename stem when
|
||||
# the file has no title tag — strip a leading track-number prefix off that
|
||||
# stem (#890) so "01 - Sun It Rises" scores against "Sun It Rises".
|
||||
# the file has no title tag.
|
||||
title = file_tags.get('title') or os.path.splitext(os.path.basename(file_path))[0]
|
||||
from core.imports.paths import strip_leading_track_number
|
||||
title = strip_leading_track_number(title)
|
||||
track_name = track.get('name', '')
|
||||
score += similarity(title, track_name) * TITLE_WEIGHT
|
||||
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
"""Resolve a track's position WITHIN its album's track list.
|
||||
|
||||
The bug this fixes: a track auto-downloaded from the playlist pipeline / wishlist /
|
||||
watchlist is identified as belonging to an album, but the per-track position is
|
||||
unknown — Deezer's search/track and MusicBrainz's recording lookups don't carry a
|
||||
track position (only their album endpoint does). ``detect_album_info_web`` then
|
||||
leaves ``track_number = None``, the import pipeline falls through to the default-1
|
||||
floor, and the file lands as ``01/1`` even though the album is known
|
||||
(``core/imports/context.py``). Verified live: e.g. Deezer says "Obelisk" is track
|
||||
9 of *The Grand Mirage*, but it was tagged 1/1.
|
||||
|
||||
This is the pure matcher: given the album's track list (fetched by the caller via
|
||||
``core.metadata.album_tracks.get_album_tracks_for_source`` — so this stays
|
||||
source-agnostic and I/O-free) plus the track's own identifiers, return its real
|
||||
``(track_number, disc_number)``. Match priority is by reliability:
|
||||
|
||||
1. **ISRC** — an exact recording identity; trusted immediately.
|
||||
2. **source track id** — exact within this album.
|
||||
3. **normalized title** — last resort.
|
||||
|
||||
Returns ``(None, None)`` on no confident match, so the caller keeps its existing
|
||||
behaviour (never worse than today).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
|
||||
def _norm_title(value: Any) -> str:
|
||||
"""Lower, strip punctuation, collapse whitespace — for tolerant title match."""
|
||||
s = re.sub(r"[^\w\s]", "", str(value or "").lower())
|
||||
return re.sub(r"\s+", " ", s).strip()
|
||||
|
||||
|
||||
def _pos_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
n = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return n if n >= 1 else None
|
||||
|
||||
|
||||
def resolve_track_position_in_album(
|
||||
album_tracks: List[dict],
|
||||
*,
|
||||
title: str = "",
|
||||
track_id: str = "",
|
||||
isrc: str = "",
|
||||
) -> Tuple[Optional[int], Optional[int]]:
|
||||
"""Return ``(track_number, disc_number)`` for this track within ``album_tracks``,
|
||||
or ``(None, None)`` when no confident match is found.
|
||||
|
||||
``album_tracks`` is the list under ``get_album_tracks_for_source(...)['tracks']``
|
||||
— each entry has ``track_number`` / ``disc_number`` / ``id`` / ``name`` / ``isrc``.
|
||||
Entries without a valid positive ``track_number`` are skipped. Pure: no I/O.
|
||||
"""
|
||||
if not album_tracks:
|
||||
return (None, None)
|
||||
|
||||
want_isrc = str(isrc or "").strip().upper()
|
||||
want_id = str(track_id or "").strip()
|
||||
want_title = _norm_title(title)
|
||||
|
||||
by_id: Optional[Tuple[int, int]] = None
|
||||
by_title: Optional[Tuple[int, int]] = None
|
||||
|
||||
for t in album_tracks:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
tn = _pos_int(t.get("track_number"))
|
||||
if tn is None:
|
||||
continue
|
||||
dn = _pos_int(t.get("disc_number")) or 1
|
||||
|
||||
# 1) ISRC — exact recording. Win immediately.
|
||||
if want_isrc and str(t.get("isrc") or "").strip().upper() == want_isrc:
|
||||
return (tn, dn)
|
||||
# 2) source track id — exact within the album.
|
||||
if by_id is None and want_id and str(t.get("id") or "").strip() == want_id:
|
||||
by_id = (tn, dn)
|
||||
# 3) normalized title — last resort.
|
||||
if by_title is None and want_title and _norm_title(t.get("name")) == want_title:
|
||||
by_title = (tn, dn)
|
||||
|
||||
if by_id is not None:
|
||||
return by_id
|
||||
if by_title is not None:
|
||||
return by_title
|
||||
return (None, None)
|
||||
|
||||
|
||||
__all__ = ["resolve_track_position_in_album"]
|
||||
|
|
@ -8,10 +8,6 @@ from __future__ import annotations
|
|||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("imports.context")
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> Dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
|
@ -133,36 +129,30 @@ def get_import_search_result(context: Optional[Dict[str, Any]]) -> Dict[str, Any
|
|||
|
||||
|
||||
def get_import_source(context: Optional[Dict[str, Any]]) -> str:
|
||||
# Several track payloads carry the metadata source under "_source" rather
|
||||
# than "source" (the discography/wishlist dicts, frontend search results).
|
||||
# Only the context-level "_source" was honored (normalize_import_context);
|
||||
# the nested dicts were checked for "source" alone, so a Deezer-sourced
|
||||
# Download Now resolved to '' and source-specific metadata logic (the
|
||||
# Deezer contributors upgrade for multi-artist tags) never ran (Netti93).
|
||||
if not isinstance(context, dict):
|
||||
return ""
|
||||
|
||||
source = context.get("source") or context.get("_source")
|
||||
source = context.get("source")
|
||||
if source:
|
||||
return str(source)
|
||||
|
||||
track_info = get_import_track_info(context)
|
||||
source = _first_value(track_info, "source", "_source", default="")
|
||||
source = _first_value(track_info, "source", default="")
|
||||
if source:
|
||||
return str(source)
|
||||
|
||||
original_search = get_import_original_search(context)
|
||||
source = _first_value(original_search, "source", "_source", default="")
|
||||
source = _first_value(original_search, "source", default="")
|
||||
if source:
|
||||
return str(source)
|
||||
|
||||
album = get_import_context_album(context)
|
||||
source = _first_value(album, "source", "_source", default="")
|
||||
source = _first_value(album, "source", default="")
|
||||
if source:
|
||||
return str(source)
|
||||
|
||||
artist = get_import_context_artist(context)
|
||||
source = _first_value(artist, "source", "_source", default="")
|
||||
source = _first_value(artist, "source", default="")
|
||||
return str(source) if source else ""
|
||||
|
||||
|
||||
|
|
@ -183,12 +173,7 @@ def get_import_clean_title(
|
|||
if not title:
|
||||
track_info = get_import_track_info(context)
|
||||
title = _first_value(track_info, "name", "title", default="")
|
||||
title = str(title or default)
|
||||
# #890: strip a leading track-number prefix that leaked from a filename stem
|
||||
# (e.g. "01 - Sun It Rises" → "Sun It Rises") so it matches the canonical title.
|
||||
# Conservative — clean source titles ("7 Rings" etc.) pass through untouched.
|
||||
from core.imports.paths import strip_leading_track_number
|
||||
return strip_leading_track_number(title)
|
||||
return str(title or default)
|
||||
|
||||
|
||||
def get_import_clean_album(
|
||||
|
|
@ -328,11 +313,7 @@ def build_import_album_info(
|
|||
(album_info or {}).get("track_number")
|
||||
or track_info.get("track_number")
|
||||
or original_search.get("track_number")
|
||||
# "Track 01" bug: default to 0 (the codebase's "unknown" sentinel,
|
||||
# same as total_tracks below), NOT 1. A fabricated 1 looks
|
||||
# authoritative and blocks the pipeline's downstream recovery
|
||||
# (embedded file tag / resolve chain); 0 lets it fall through.
|
||||
or 0
|
||||
or 1
|
||||
)
|
||||
disc_number = (
|
||||
(album_info or {}).get("disc_number")
|
||||
|
|
@ -433,152 +414,20 @@ def detect_album_info_web(context, artist_context=None):
|
|||
track_name.strip().lower(),
|
||||
artist_name.strip().lower(),
|
||||
}:
|
||||
_tn = track_info.get("track_number")
|
||||
_dn = track_info.get("disc_number")
|
||||
# The album is identified but discovery often doesn't carry the per-track
|
||||
# POSITION — Deezer's search/track and MusicBrainz's recording lookups omit
|
||||
# it (only their album endpoint has it). Without a position the pipeline
|
||||
# falls through to the default-1 floor and files an album track as 01/1
|
||||
# (e.g. Deezer says "Obelisk" is track 9 of The Grand Mirage). Resolve the
|
||||
# REAL position from the album's own track list when we have its id.
|
||||
# Fail-safe: leaves the numbers untouched on any miss, so behaviour is
|
||||
# never worse than the old preserve-None-and-fall-through.
|
||||
if _tn is None:
|
||||
_tn, _dn = _resolve_album_position_from_source(context, artist_context, _dn)
|
||||
return build_import_album_info(
|
||||
context,
|
||||
album_info={
|
||||
"album_name": album_name,
|
||||
"track_number": _tn,
|
||||
"disc_number": _dn,
|
||||
# Preserve missing numbers as None so the import pipeline
|
||||
# can fall through to ``extract_track_number_from_filename``
|
||||
# at ``core/imports/pipeline.py:652`` instead of locking
|
||||
# to track/disc 01 for every wishlist re-attempt.
|
||||
"track_number": track_info.get("track_number"),
|
||||
"disc_number": track_info.get("disc_number"),
|
||||
"album_image_url": album_ctx.get("image_url", ""),
|
||||
"confidence": 0.5,
|
||||
},
|
||||
force_album=True,
|
||||
)
|
||||
|
||||
# Last resort: the track matched a SINGLE with no usable album context —
|
||||
# look up the parent ALBUM that actually contains it (gated, fail-safe).
|
||||
return _resolve_single_to_parent_album(context, artist_context)
|
||||
|
||||
|
||||
def _resolve_album_position_from_source(context, artist_context, current_disc):
|
||||
"""Look up a track's real ``(track_number, disc_number)`` from its album's track
|
||||
list, for the case where the album is known but discovery didn't carry a
|
||||
position (Deezer/MusicBrainz search omit it).
|
||||
|
||||
Uses the SAME album id discovery already resolved (``get_import_source_ids`` →
|
||||
``album_id``), so it re-homes the track onto its own album with no re-search and
|
||||
no edition guessing. Matches by ISRC → source track id → title via the pure
|
||||
``core.imports.album_position`` seam. Returns ``(None, current_disc)`` on any
|
||||
miss/error so the caller falls back exactly as before — never worse than today.
|
||||
"""
|
||||
try:
|
||||
source = get_import_source(context)
|
||||
ids = get_import_source_ids(context)
|
||||
album_id = str(ids.get("album_id") or "")
|
||||
if not source or not album_id:
|
||||
return None, current_disc
|
||||
|
||||
from core.metadata.album_tracks import get_album_tracks_for_source
|
||||
payload = get_album_tracks_for_source(source, album_id) or {}
|
||||
tracks = payload.get("tracks") or []
|
||||
if not tracks:
|
||||
return None, current_disc
|
||||
|
||||
track_info = get_import_track_info(context)
|
||||
original_search = get_import_original_search(context)
|
||||
title = (track_info.get("name") or original_search.get("title") or "").strip()
|
||||
isrc = str(track_info.get("isrc") or original_search.get("isrc") or "")
|
||||
|
||||
from core.imports.album_position import resolve_track_position_in_album
|
||||
tn, dn = resolve_track_position_in_album(
|
||||
tracks, title=title, track_id=str(ids.get("track_id") or ""), isrc=isrc)
|
||||
if tn is not None:
|
||||
logger.info("album-position: resolved '%s' to track %s/disc %s from album %s (%s)",
|
||||
title, tn, dn, album_id, source)
|
||||
return tn, (dn if dn is not None else current_disc)
|
||||
return None, current_disc
|
||||
except Exception as e:
|
||||
logger.debug("album-position resolution failed: %s", e)
|
||||
return None, current_disc
|
||||
|
||||
|
||||
def _resolve_single_to_parent_album(context, artist_context):
|
||||
"""A single-matched track -> a promoted album_info for its parent album, or
|
||||
None. GATED by ``metadata_enhancement.single_to_album`` (default OFF — it's a
|
||||
per-import metadata lookup, so it's opt-in). Fail-safe: any miss/error returns
|
||||
None so the track stays exactly as it was matched (never worse than today)."""
|
||||
try:
|
||||
from core.metadata.common import get_config_manager
|
||||
if not get_config_manager().get("metadata_enhancement.single_to_album", False):
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
try:
|
||||
source = get_import_source(context)
|
||||
track_info = get_import_track_info(context)
|
||||
original_search = get_import_original_search(context)
|
||||
track_title = (track_info.get("name") or original_search.get("title") or "").strip()
|
||||
artist_name = (extract_artist_name(artist_context)
|
||||
or get_import_clean_artist(context, default="")).strip()
|
||||
if not source or not track_title or not artist_name:
|
||||
return None
|
||||
artist_id = str(get_import_source_ids(context).get("artist_id") or "")
|
||||
|
||||
from core.metadata.album_tracks import (
|
||||
get_artist_albums_for_source,
|
||||
get_artist_album_tracks,
|
||||
)
|
||||
from core.imports.single_to_album import resolve_single_to_album
|
||||
|
||||
def _acc(o, *ks):
|
||||
for k in ks:
|
||||
v = o.get(k) if isinstance(o, dict) else getattr(o, k, None)
|
||||
if v:
|
||||
return v
|
||||
return None
|
||||
|
||||
def fetch_candidates():
|
||||
albums = get_artist_albums_for_source(
|
||||
source, artist_id, artist_name=artist_name,
|
||||
album_type="album", limit=20) or []
|
||||
return [{"name": _acc(a, "name", "title"),
|
||||
"album_type": _acc(a, "album_type") or "album",
|
||||
"id": _acc(a, "id", "album_id")} for a in albums]
|
||||
|
||||
def fetch_tracks(alb):
|
||||
payload = get_artist_album_tracks(
|
||||
str(alb.get("id") or ""), artist_name=artist_name,
|
||||
album_name=alb.get("name") or "") or {}
|
||||
return [(_acc(t, "title", "name", "track_name") or "")
|
||||
for t in (payload.get("tracks") or [])]
|
||||
|
||||
album = resolve_single_to_album(
|
||||
track_title,
|
||||
fetch_album_candidates=fetch_candidates,
|
||||
fetch_album_tracks=fetch_tracks)
|
||||
if not album or not album.get("name"):
|
||||
return None
|
||||
logger.info("single->album: re-homed '%s' onto parent album '%s'",
|
||||
track_title, album["name"])
|
||||
promoted = build_import_album_info(
|
||||
context,
|
||||
album_info={
|
||||
"album_name": album["name"],
|
||||
"track_number": track_info.get("track_number"),
|
||||
"disc_number": track_info.get("disc_number"),
|
||||
"album_image_url": "",
|
||||
"confidence": 0.5,
|
||||
},
|
||||
force_album=True,
|
||||
)
|
||||
# build_import_album_info resolves album_name via get_import_clean_album,
|
||||
# which prefers original_search.album (the SINGLE's name); override it
|
||||
# with the resolved parent album so grouping + tags use the album.
|
||||
promoted["album_name"] = album["name"]
|
||||
return promoted
|
||||
except Exception as e:
|
||||
logger.debug("single->album resolution failed: %s", e)
|
||||
return None
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -52,14 +52,6 @@ _DEFAULT_LENGTH_TOLERANCE_S = 3.0
|
|||
_LENGTH_TOLERANCE_LONG_TRACK_S = 5.0
|
||||
_LONG_TRACK_THRESHOLD_S = 600.0 # 10 minutes
|
||||
|
||||
# A file that runs LONGER than the expected metadata is the opposite of a truncated
|
||||
# download — it's almost always a different master/version (a remaster with a longer
|
||||
# outro, an extended fade, an album cut vs the radio edit). The duration check exists to
|
||||
# catch TRUNCATION (short files) and wildly-wrong matches, so on the auto default we allow
|
||||
# more drift in the longer direction and keep the tight bound for short files. A wrong-song
|
||||
# match still trips this — it's usually off by far more than 15s. (#937)
|
||||
_LONGER_VERSION_TOLERANCE_S = 15.0
|
||||
|
||||
# Upper bound for the user-configurable override. Anything past 60s
|
||||
# means the check is effectively off — cap defends against accidental
|
||||
# nonsense like 9999 making logs misleading. Users who genuinely want
|
||||
|
|
@ -92,26 +84,6 @@ def resolve_duration_tolerance(value: Any) -> Optional[float]:
|
|||
return parsed
|
||||
|
||||
|
||||
def expected_duration_for_check(expected_ms: Any, is_local_import: bool) -> Optional[int]:
|
||||
"""The expected duration (ms) to run the duration-agreement leg against,
|
||||
or None to skip that leg.
|
||||
|
||||
The duration check exists to catch BROKEN slskd TRANSFERS (truncated /
|
||||
wrong-file downloads). A local/manual import is the user's own already-
|
||||
tagged file being sorted, not a transfer — duration-agreeing it against a
|
||||
re-resolved release is meaningless and produces false quarantines (#804:
|
||||
Coldplay "Yellow" album file, 269s, false-rejected against a *single*
|
||||
edition's 266s). So for local imports we skip the duration leg; the
|
||||
size + mutagen-parse legs still run and catch genuinely broken files.
|
||||
"""
|
||||
if is_local_import:
|
||||
return None
|
||||
try:
|
||||
return int(expected_ms) or None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntegrityResult:
|
||||
"""Outcome of an integrity check.
|
||||
|
|
@ -211,29 +183,11 @@ def check_audio_integrity(
|
|||
checks["actual_length_s"] = actual_length_s
|
||||
|
||||
if actual_length_s <= 0:
|
||||
# Length 0 is NOT proof of corruption here: the file already passed the
|
||||
# size gate, was identified as a real audio format, and has a valid
|
||||
# info block. A genuinely empty/truncated/stub file fails one of those
|
||||
# earlier checks instead. The real cause of a clean-but-zero-length
|
||||
# parse is "length unknown" — fragmented / streamed FLAC carries
|
||||
# total_samples=0 in its STREAMINFO even though every audio frame is
|
||||
# present and the file plays fine. HiFi is the common trigger: it
|
||||
# assembles FLAC from HLS segments and demuxes with `ffmpeg -c copy`,
|
||||
# which preserves total_samples=0, so mutagen computes length 0 and the
|
||||
# file was wrongly quarantined (#756). Treat it as unknown length:
|
||||
# accept the file and skip the duration cross-check we can't perform
|
||||
# without a length. mutagen never decoded/validated frame data anyway,
|
||||
# so accepting here doesn't weaken real corruption detection.
|
||||
logger.warning(
|
||||
"[Integrity] %s parsed cleanly (%d bytes, format=%s) but reports "
|
||||
"length 0 — treating as unknown length (likely streamed/fragmented "
|
||||
"FLAC), not rejecting",
|
||||
os.path.basename(file_path), size, type(audio).__name__,
|
||||
)
|
||||
return IntegrityResult(
|
||||
ok=True,
|
||||
checks={**checks, "mutagen_parse": "zero_length_unknown",
|
||||
"length_check": "skipped_unknown_length"},
|
||||
ok=False,
|
||||
reason="Mutagen reports zero-length audio — file has no playable "
|
||||
"audio data",
|
||||
checks={**checks, "mutagen_parse": "zero_length"},
|
||||
)
|
||||
|
||||
# --- Check 3: duration agreement (optional) ---
|
||||
|
|
@ -250,32 +204,18 @@ def check_audio_integrity(
|
|||
if expected_length_s > _LONG_TRACK_THRESHOLD_S
|
||||
else _DEFAULT_LENGTH_TOLERANCE_S
|
||||
)
|
||||
user_pinned_tolerance = False
|
||||
else:
|
||||
user_pinned_tolerance = True
|
||||
checks["length_tolerance_s"] = length_tolerance_s
|
||||
|
||||
# Positive drift = the file runs LONGER than expected (not truncation). On the auto
|
||||
# default, give the longer direction more room so legit longer masters/versions aren't
|
||||
# quarantined (#937); a user-pinned tolerance is honoured symmetrically.
|
||||
signed_drift_s = actual_length_s - expected_length_s
|
||||
drift_s = abs(signed_drift_s)
|
||||
drift_s = abs(actual_length_s - expected_length_s)
|
||||
checks["length_drift_s"] = drift_s
|
||||
effective_tolerance_s = length_tolerance_s
|
||||
if signed_drift_s > 0 and not user_pinned_tolerance:
|
||||
effective_tolerance_s = max(length_tolerance_s, _LONGER_VERSION_TOLERANCE_S)
|
||||
checks["effective_tolerance_s"] = effective_tolerance_s
|
||||
|
||||
if drift_s > effective_tolerance_s:
|
||||
runs_long = signed_drift_s > 0
|
||||
if drift_s > length_tolerance_s:
|
||||
return IntegrityResult(
|
||||
ok=False,
|
||||
reason=f"Duration mismatch: file is {actual_length_s:.1f}s, "
|
||||
f"expected {expected_length_s:.1f}s "
|
||||
f"(drift {drift_s:.1f}s > tolerance {effective_tolerance_s:.1f}s) — "
|
||||
+ ("runs longer than expected — likely a different version/master or wrong file"
|
||||
if runs_long
|
||||
else "likely truncated download or wrong file matched"),
|
||||
f"(drift {drift_s:.1f}s > tolerance {length_tolerance_s:.1f}s) — "
|
||||
"likely truncated download or wrong file matched",
|
||||
checks=checks,
|
||||
)
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue