- Add discogs_artist_id column to watchlist_artists table (migration)
- Add discogs_artist_id to WatchlistArtist dataclass
- Add to get_watchlist_artists optional_columns and constructor
- Add update_watchlist_discogs_id DB method
- Backfill loop includes Discogs when token is configured
- Add _match_to_discogs for cross-provider artist matching
- Backfill maps updated: id_attr, match_fn, update_fn all include discogs
- Add _extract_discogs_fields to metadata cache — handles Discogs field
names (title vs name, images array, Artist - Title format)
- Worker uses _fetch_and_cache_artist/_fetch_and_cache_album helpers
that cache raw data while returning it for enrichment
- All search/lookup methods cache results for repeat queries
- Cache browser: Discogs stat pill, source filter, clear button, badge
- Fixes albums showing as 'Unknown' and artists missing images in cache
- get_album and get_album_tracks now try /masters/{id} first, fall back
to /releases/{id} — artist discography returns master IDs which are
in a different namespace than release IDs
- Fixes wrong album showing in download modal (master ID 3664443 for
GNX was hitting /releases/3664443 which is a different album)
- Add Discogs source override to all 6 artist/album/track endpoints
- Add discogs_id to _resolve_db_album_id lookup
- Remove upfront master detail fetching (was 15+ API calls, 40+ seconds)
- Discography loads from releases list only (~1 second, 2 API calls)
- Track counts populate on-demand via get_album_tracks when clicking album
- New get_album_tracks method: tries /masters first, falls back to /releases,
returns Spotify-compatible format with proper disc/track numbering
- Album type defaults to 'album' for masters without format metadata —
Discogs limitation, singles only detectable from individual release format
- Search results return format as list ['Vinyl', 'LP'] while artist
releases return comma-separated string — handle both
- Fixes "'list' object has no attribute 'lower'" error
- Master releases now fetch /masters/{id} to get actual tracklist,
genres, styles, and images — fixes 0/0 track count display
- Album type re-evaluated with real track count: 1-3 = single,
4-6 = EP, 7+ = album
- Cover art from master detail used when search results have none
- Tested: Kendrick Lamar shows correct track counts, proper types,
and images for all albums
- Fetch artist name first, then compare against each release's primary
artist — skip releases where the artist is listed after Feat./Ft./&
- "Beyoncé Feat. Kendrick Lamar" → skipped (Kendrick is featured)
- "Kendrick Lamar Feat. Rihanna" → kept (Kendrick is primary)
- Fixes artist pages showing unrelated albums from other artists
- Add _normalize_name helper and re import to DiscogsClient
- Prefer master releases over individual pressings to avoid duplicates
(multiple pressings of same album showing separately)
- Individual releases only included if no master exists for that title
- Skip non-main roles (appearances, features, remixes by others)
- Better album type detection from format string: catches LP, Album,
EP, Single, Compilation from comma-separated format field
- Fetch more results (3x limit) to compensate for filtering
- Fix source name mapping so sidebar/dashboard shows 'Discogs' instead
of falling through to 'iTunes'
- Fix album type detection: parse format string from artist releases
endpoint (e.g. "File, FLAC, Single, 320") to correctly identify
singles, EPs, albums, compilations — was defaulting everything to
'single' because track count was 0
- Remove fake track search that returned albums as tracks — Discogs
has no track-level search API, so tracks section is empty (honest)
- Track data available via album tracklists instead
- SpotifyClient: add _discogs lazy-load property, route _fallback to
DiscogsClient when configured (requires token, falls back to iTunes)
- web_server: _get_metadata_fallback_client returns DiscogsClient when
selected and token present
- Enhanced search: Discogs added as source tab with NDJSON streaming,
only available when token configured
- Alternate sources list includes Discogs when token is set
- Frontend: source labels, tab styling, fetch list all include Discogs
- Consistent with iTunes/Deezer pattern — same interfaces, same routing
- New core/discogs_worker.py — background worker enriching artists and
albums with Discogs metadata following AudioDBWorker pattern exactly
- Artist enrichment: discogs_id, bio, members, URLs, image backfill,
genre backfill, summary backfill from bio
- Album enrichment: discogs_id, genres, styles (400+ taxonomy), label,
catalog number, country, community rating, image backfill
- DB migration: discogs columns on artists (id, match_status, bio,
members, urls) and albums (id, match_status, genres, styles, label,
catno, country, rating, rating_count)
- Worker initialization with pause/resume persistence
- Status/pause/resume API endpoints
- Integrated into enrichment status system, rate monitor, auto-pause
during downloads/scans, WebSocket status emission
- New Discogs section on Settings → Connections with personal token input
- Discogs added as fallback metadata source option alongside iTunes/Deezer
- Token saved to discogs.token config key
- Discogs added to API rate monitor gauges (60/min with auth)
- Help text links to discogs.com/settings/developers for token generation
- Full parity with iTunes/Deezer clients — same Track/Artist/Album
dataclasses, same method signatures (search_artists, search_albums,
search_tracks, get_artist, get_album, get_artist_albums)
- 25 req/min unauthenticated, 60 req/min with free personal token
- Rate limited via same decorator pattern with API call tracking
- Unique data: 400+ genre/style taxonomy, label info, catalog numbers,
community ratings, artist bios
- Smart "Artist - Title" parsing for search results
- Release deduplication (Discogs has many pressings of same album)
- Track search via release tracklist extraction
- Tested: artist/album/track search, artist detail with bio, album
detail with full tracklist + genres + styles + label
- Was only backfilling the active provider — artists added via Deezer
never got Spotify/iTunes IDs, and vice versa
- Now backfills iTunes (always), Deezer (always), and Spotify (if
authenticated) at the start of every scan
- Added _match_to_deezer() and update_watchlist_deezer_id() for
Deezer cross-provider matching
- Generalized backfill with provider→attribute/function maps
- New 'webhook' then-action: sends HTTP POST with JSON payload to any
user-configured URL (Gotify, Home Assistant, Slack, n8n, etc.)
- Config: URL, optional custom headers (Key: Value per line with
variable substitution), optional custom message
- Payload includes all event variables as JSON fields
- 15s timeout, errors on 400+ status codes
- Follows exact same pattern as Discord/Pushbullet/Telegram handlers
- Frontend: config fields, config reader, icon, help docs
- Updated changelogs with webhook, M3U fix, orchestrator hardening
- Each of the 6 download clients initializes independently via
_safe_init() — one failing client no longer kills the orchestrator
- All methods guarded against None clients with appropriate fallbacks
- Init failures logged at startup and tracked in _init_failures list
- Copy Debug Info shows "Download Client Failures" section when any
client failed to initialize, or "ALL" if orchestrator itself is dead
- Add rate limiting to all 4 Spotify pagination loops (get_artist_albums,
get_user_playlists, get_playlist_tracks, get_album_tracks) — these
called sp.next() bypassing the rate_limited decorator entirely, causing
unthrottled API calls that triggered 429 bans
- Track pagination calls in API rate monitor (separate endpoint names)
- Increase DELAY_BETWEEN_ARTISTS from 2s to 4s in watchlist scanner
- Abort watchlist scan immediately if Spotify rate limit detected mid-scan
instead of continuing to hammer the API
- New core/api_call_tracker.py — centralized tracker with rolling 60s
timestamps (speedometer) and 24h minute-bucketed history (charts)
- Instrument all 9 service client rate_limited decorators to record
actual API calls with per-endpoint tracking for Spotify
- 1-second WebSocket push loop for real-time gauge updates
- Modern radial arc gauges with service brand colors, glowing active
arc, endpoint dot, 0/max scale labels, smooth CSS transitions
- Click any gauge to open detail modal with 24h call history chart
(Canvas 2D, HiDPI, gradient fill, grid lines, danger zone band)
- Spotify modal shows per-endpoint history lines with color legend
and live per-endpoint breakdown bars
- Rate limited state indicator — blinking red badge with countdown
timer appears on gauge card when Spotify ban is active
- REST endpoint GET /api/rate-monitor/history/<service> for chart data
- Responsive grid layout (5 cols desktop, 3 tablet, 2 phone)
- New discovery_artist_blacklist table with NOCASE name matching
- Filter blacklisted artists from all 6 discovery pool queries, hero
endpoint, and recent releases via SQL subquery and Python set check
- Name-based filtering means one block covers all sources (Spotify/iTunes/Deezer)
- Hover any discovery track row → ✕ button to quick-block that artist
- 🚫 button on Discover hero opens management modal with search-to-add
(powered by enhanced search) and list of blocked artists with unblock
- CRUD API: GET/POST/DELETE /api/discover/artist-blacklist
- Updated changelogs
- Add MusicBrainz to Cache Browser: stats pill, source filter, dedicated
browse endpoint, cards with matched/failed status indicators
- Add Clear MusicBrainz and Clear Failed MB Only to cache clear dropdown
- Move MusicBrainz into Cache Health "By Source" bar chart alongside
Spotify/iTunes/Deezer instead of isolated metric row
- Rename ambiguous "Failed Lookups" to "Failed MB Lookups" in summary cards
- Add browse-musicbrainz and clear-musicbrainz API endpoints
- Add musicbrainz_total/musicbrainz_failed to cache stats response
- Add Global Search Bar and MusicBrainz cache to changelogs
Genius rate limits are undocumented but users hit 429s at ~40 req/min.
Bumping interval from 1.5s to 2s drops throughput to ~30 req/min which
stays under the threshold. No functional change — just slower enrichment.
The manual match modal for Genius only returned 0 or 1 artist result
because search_artist() searched songs (per_page=5), extracted the
primary artist, and returned the first match or None.
Added search_artists() that returns multiple unique artists extracted
from song results with broader search (per_page=20). The manual match
endpoint now shows up to 8 artist candidates and multiple track results
instead of one-or-nothing. Also shows the Genius URL as extra info.
The previous commit only added skip_cache=True to one call site in
web_server.py. The watchlist scanner in core/watchlist_scanner.py has
6 Spotify get_artist_albums calls that also need fresh data to detect
new releases. All now bypass cache. iTunes/Deezer calls are unaffected
(they don't have the skip_cache param, detected via hasattr check).
Five places in web_server.py called spotify_client.sp.search() directly,
bypassing the cached search_tracks()/search_artists() methods. Each
discovery worker (Tidal, YouTube, ListenBrainz, Beatport) was also
doubling API calls — sp.search() for raw data then search_tracks() for
Track objects.
Now all use cached methods only. Raw track data for album art is
retrieved from the metadata cache by track ID after matching. Also fixed
a pre-existing bug where Tidal discovery could pair stale Spotify raw
data with a newer iTunes match.
Bumped is_spotify_authenticated() probe cache TTL from 5 to 15 minutes
to reduce /v1/me calls (~288/day → ~96/day). Manual disconnect still
takes effect immediately via _invalidate_auth_cache().
The cache stores raw_data through _extract_fields which expects a dict
with a 'name' field. Storing a raw list caused silent AttributeError,
and storing a dict without 'name' triggered junk entity rejection
(empty string is in _JUNK_NAMES). Now wraps the albums list in a dict
with a valid name field so it passes validation and persists correctly.
The watchlist auto-scan needs fresh data from Spotify to detect new
releases, so it bypasses the cache added in the previous commit.
All other callers (UI browsing, completion badges, discography views)
continue to benefit from cached results.
get_artist_albums was making fresh API calls on every invocation with
no cache check, despite being one of the most called methods (discography
views, completion badges, watchlist scans). The method already cached
individual albums opportunistically but never checked for a cached result
before hitting the API.
Now follows the same check-then-fetch-then-cache pattern used by
get_album_tracks and get_artist. Cache key includes album_type param
so different queries (album,single vs compilation) are cached separately.
YouTube's auto-generated artist channels use the format "Artist - Topic"
as the channel name. This suffix was not being stripped during playlist
parsing, causing metadata discovery to fail (e.g., searching for
"Koven - Topic" instead of "Koven" on iTunes/Deezer).
Fixed in all three places where YouTube artist names are cleaned:
- web_server.py clean_youtube_artist() — playlist parsing
- ui/pages/sync.py clean_youtube_artist() — UI-side parsing
- core/youtube_client.py — search result fallback artist extraction
Large libraries (first import) can take longer than 10 seconds for
getArtists to respond. The short timeout caused the library fetch
to fail with 0 artists returned.
The dashboard status poll and hybrid connection check were pinging
slskd every 2 minutes regardless of download source, flooding logs
with connection errors when slskd wasn't running. Now only checks
slskd when the download mode is 'soulseek' or when 'soulseek' is
in the hybrid order. Hybrid mode also only checks sources in the
configured priority order instead of all six.
The metadata-only optimization broke two things:
1. Cards showed 0 tracks because tracks were no longer in the listing
2. Auto-mirror skipped all playlists because tracks array was empty
Fix: cards render instantly from metadata, then tracks are fetched
per-playlist in the background via /api/tidal/playlist/<id>. As each
playlist's tracks arrive, the card count updates and the playlist
is auto-mirrored. Also tried multiple V2 attribute names for track
count (numberOfTracks, numberOfItems, etc.) and fixed the card DOM
selector for count updates.
get_user_playlists_metadata_only() was fetching full track lists for
every playlist sequentially (1+ API calls per playlist with 1s sleep
between pagination pages). For 20+ playlists this took 30-60 seconds.
Now returns only metadata (name, ID, track count, image) from a
single V2 API call. Track count comes from the numberOfTracks
attribute. Tracks are fetched on-demand when the user selects a
specific playlist to sync/mirror via the existing get_playlist()
endpoint.
Fresh installs now default to hybrid download mode (HiFi → YouTube →
Soulseek) instead of Soulseek-only, and Deezer as the metadata
fallback source instead of iTunes. Existing users with saved settings
are unaffected — defaults only apply when config keys don't exist.
The original #221 fix only covered Genius and AudioDB. All other
workers (Spotify, iTunes, Last.fm, MusicBrainz, Deezer, Tidal,
Qobuz) had the same bug: enrichment overwrites manual match status
to not_found when name search fails. Each worker now checks for an
existing service ID before searching by name and returns early if
one exists, preserving the manual match.
When spotipy exhausts all retries on 429 errors, the actual
Retry-After value (often 10+ hours) is consumed internally by
spotipy and not passed in the exception. The default ban was
only 1 hour, causing an endless retry cycle. Increased to 4
hours to match the escalation max and give Spotify's ban time
to expire.
normalize_string() was running unidecode on all text, converting
Japanese kanji to Chinese pinyin gibberish (命の灯火 → "tvanimedei").
Now detects CJK characters (kanji, hiragana, katakana, hangul,
fullwidth forms) and skips unidecode for text containing them —
just lowercases instead. Non-CJK text (Latin accents, Cyrillic)
still goes through unidecode normally.
Plain (unsynced) lyrics were being saved with .lrc extension despite
having no timestamps, making them invalid for Plex and other players
that expect LRC format. Synced lyrics now write as .lrc, plain lyrics
write as .txt. Both types still get embedded in audio file tags.
Updated all file move/rename operations to handle .txt sidecars
alongside .lrc.
When a user manually matched an artist to a service ID then triggered
enrichment, the worker re-searched by name, failed to find a match,
and overwrote the status back to not_found — despite the ID being
valid. Now both Genius and AudioDB workers check for existing service
IDs before searching by name. If an ID exists (from manual match),
the worker uses it for a direct API lookup to enrich metadata while
preserving the matched status. Added AudioDB lookup-by-ID client
methods for artist, album, and track.
The tooltip only checked paused/authenticated/idle/running states.
When Spotify was rate limited or daily budget exhausted, the worker
thread was still alive (sleeping in guards) so it showed "Running"
with no current item and stale 0% progress.
Now checks rate_limited and daily_budget.exhausted before running:
- Rate limited: "Rate Limited — Waiting Xm for rate limit to clear"
- Budget exhausted: "Daily Limit Reached — Resets in Xh Xm"
- No current item: "Waiting for next item..." instead of blank
Also adds rate_limit info object to get_stats() response for the
countdown display.
Cache maintenance:
- Input validation rejects junk entities (Unknown Artist, empty names)
from being cached, with exemptions for synthetic entries (_features,
_tracks suffixes)
- CacheEvictorJob expanded to 4 phases: TTL eviction, junk cleanup,
orphaned search cleanup, MusicBrainz failed lookup cleanup
- MusicBrainz null results now expire after 30 days (was 90) so failed
lookups get retried sooner
Cache health UI:
- Polished modal accessible from Dashboard "Cache Health" button and
repair dashboard health bar
- Shows health status banner (healthy/fair/poor), stat cards, source
breakdown with colored progress bars, type pills, and metrics table
- Repair dashboard shows compact bar with health dot indicator
When the fingerprint score is >=0.95 but title/artist don't match
(e.g. English expected vs Japanese returned), SKIP instead of FAIL.
A 95%+ fingerprint means the audio IS the correct recording — the
metadata mismatch is just a language/script difference, not a wrong
file. Prevents Japanese, Chinese, Korean, and other non-Latin tracks
from being falsely quarantined.
Happy path unchanged — matching title/artist still returns PASS at
the earlier check before this code is reached.
When artist or title contains non-ASCII characters (Japanese, Chinese,
Korean, etc.), prepend the original un-romanized text as the first
search query. unidecode converts Japanese kanji to Chinese pinyin
(e.g. "藤澤慶昌" → "wu zhi zhuan sheng") which never matches on
Soulseek. The original characters match filenames directly.
Romanized fallback queries are still generated after for coverage.
Zero impact on ASCII-only tracks (isascii check skips them).
fix_finding() was using a potentially stale transfer_folder that was
only refreshed during scheduled job runs, not on manual fix attempts.
Now re-reads the transfer path from config before each fix, matching
the same logic used by _run_next_job().
Also surfaces fix failure reasons to the user — bulk fix now logs each
failure with finding ID and error, and the frontend toast shows the
actual error message instead of just "X failed".
Artist/album/track matching previously took the first Spotify search
result above the 0.80 similarity threshold. If Spotify returned a
near-match before the exact match (e.g. "Brother's Keeper" before
"Brothers Keepers"), the wrong entity would be selected.
Now scores all candidates and picks the highest, so an exact match
(1.0) always wins over a near-match (0.94). No change to threshold
or batch matching logic — strictly better or equal results.
The background enrichment worker now caps itself at 3,000 processed items
per calendar day. Counter resets at midnight automatically. When exhausted,
the worker sleeps and checks every 5 minutes for a new day.
This is scoped entirely to the enrichment worker — user-initiated Spotify
API calls (searches, playlist ops, album lookups, etc.) are completely
unaffected. Budget status is exposed in the worker's get_stats() response
for the dashboard widget.
The image update code only checked Spotify's images array format,
missing iTunes direct image_url. Also used spotify_artist_id for
DB lookup which missed Deezer-only artists. Now handles both image
formats and tries all provider IDs for the DB update.
Old Deezer watchlist artists missing images will get backfilled
on the next watchlist scan automatically.
OggOpus (Mutagen) is a separate class from OggVorbis but uses the
same VorbisComment tag API. All isinstance checks for (FLAC, OggVorbis)
missed OggOpus, so tags were cleared but never rewritten — resulting
in empty metadata and "Unknown Artist" on the media server.
Added _is_ogg_opus() helper and applied to all 15 format checks in
web_server.py and 2 in core/tag_writer.py. No change to FLAC/OGG/
MP3/M4A handling (short-circuit evaluation skips the new check).
The fix was failing silently — returning error results without
writing to any log. Added info/warning logs at each failure point
(missing paths, source not found, path escapes transfer, destination
conflict) so Docker path resolution issues can be diagnosed.