soulsync/core
Broque Thomas a9f827ef42 Reject Tidal streams that silently downgrade from the requested quality
Reported on Discord by Netti93: with Tidal configured for "HiRes only"
and "Allow Quality Fallback" disabled, tracks were still downloading
successfully — as m4a 320kbps files. Some "successful" downloads were
less than half the file size of the same track pulled via Tidarr/tiddl
from the same Tidal account.

Root cause: Tidal's API silently degrades to the best quality your
account + the track + your region permits. Setting
`session.audio_quality = Quality.hi_res_lossless` and calling
`track.get_stream()` on a track that's only available in AAC returns
an AAC stream with no error. The downloader wrote the m4a file to
disk, the ~7MB size sailed past the 100KB stub threshold, and the
download reported success.

The pre-existing "verify quality wasn't silently downgraded" block
only LOGGED a warning when this happened; it did not fail the tier.
Two knock-on effects:

- Users with "HiRes only, no fallback" got m4a files anyway, which
  defeats the setting entirely.
- The worker-level fallback chain (hires → lossless → high → low)
  couldn't advance past the first tier, because every tier
  "succeeded" at whatever Tidal happened to serve.

Fix: after `track.get_stream()`, compare `stream.audio_quality`
against the tier we asked for using a rank-based ordering:

    LOW < HIGH < LOSSLESS < HI_RES < HI_RES_LOSSLESS

- Same tier or higher → accept (so the occasional Tidal upgrade
  doesn't get rejected just because it's not an exact match).
- Lower tier → reject THIS tier. The loop `continue`s and the next
  fallback tier is tried, or the whole download fails honestly
  when the user has fallback disabled. The existing final-error
  log already has a hint directing users to enable fallback if
  they want automatic Lossless substitution.
- Unrecognized `audioQuality` value (e.g. a new Tidal tier we
  haven't mapped) → reject conservatively, so the next fallback
  tier gets a chance and the diagnostic log names the unknown
  value.

Why the rank-based approach instead of strict equality:

Tidal's API doesn't technically promise an exact-tier match on
serving; on tracks that are flagged in its catalog as a higher
tier, it can serve higher than the session setting. Rejecting
higher-than-asked quality would be user-hostile. And the `HI_RES`
(legacy MQA) value — not in tidalapi's modern `Quality` enum but
possibly still present on old catalog entries — needs to rank
below `HI_RES_LOSSLESS`: users asking for true lossless HiRes
should reject MQA since MQA is a lossy format.

tidalapi's `Quality` enum is a `str` subclass whose VALUES (not
member names) match what the Tidal API returns in the
`audioQuality` field (e.g. `Quality.hi_res_lossless.value ==
'HI_RES_LOSSLESS'`, `Quality.low_320k.value == 'HIGH'`). Both
sides of the comparison are coerced to `str` before use, so the
check is robust to whichever tidalapi version exposes the served
quality as an enum or a plain string.

The check is extracted as `_verify_stream_tier(stream, q_info,
q_key) -> (ok, reason)` at module scope — a pure function with no
I/O, unit-tested independently. Ten tests: match, three upgrade
cases (LOSSLESS → HI_RES_LOSSLESS, LOSSLESS → HI_RES, LOW → any
higher), three downgrade cases (the reported HiRes → AAC, HiRes
Lossless → MQA HiRes, Lossless → AAC), one unrecognized-tier case,
and two defensive paths for older tidalapi builds without
`audio_quality` on the stream object and for QUALITY_MAP entries
that lack `tidal_quality` (e.g. tidalapi wasn't importable at
module load). Test stub updated to use uppercase `Quality` values
matching real tidalapi so case-sensitivity regressions get caught.

Also removed the old codec-string-based warning block — the new
tier check is strictly stronger, and keeping the warning around
would just be dead code waiting to drift out of sync.

Deliberately NOT tackling in this PR (documented as follow-ups):

- Bit-depth verification of HiRes FLAC files via mutagen. The
  `stream.audio_quality` tier check catches the main "HiRes
  requested, got AAC" case; bit-depth would only matter if Tidal
  labeled a stream HI_RES_LOSSLESS but served a 16-bit FLAC
  (`Stream.bit_depth` isn't reliable for this — tidalapi defaults
  missing `bitDepth` fields to 16, so a trust-the-stream check
  would spuriously reject valid HiRes whenever Tidal omits the
  field). A proper fix runs mutagen post-download to inspect the
  actual file, then decides whether to delete + retry the next
  tier — a whole new failure mode with design trade-offs that
  deserve their own PR. The support logs don't show this
  happening.

- The "manual remap still says Not Found" symptom. Might be
  downstream of this same bug (silent-AAC "success" hitting a
  later rejection), might be a separate task-state issue. Not
  guessing without logs from the retry path.

- Quality-aware stub threshold. 100KB is a reasonable floor for
  real stub/preview detection and there's no evidence the
  universal threshold is misfiring in the wild.

Field-verified status: desk-verified via unit tests and empirical
checks against a live tidalapi import (confirming the `Quality`
enum's str-subclass behavior). Not yet smoke-tested end-to-end
against a real Tidal account with a HiRes-only-no-fallback
setting — Netti93 or anyone else with that config should notice
either the fix working (non-HiRes tracks fail honestly with a
clear log line) or any regression before wider release.

Files:
- core/tidal_download_client.py — new `_verify_stream_tier` helper
  and `_QUALITY_RANK` table at module scope, called in the
  download loop after the stream is fetched and before any
  bandwidth is spent. Removed the old inline codec-based warning
  since the new check supersedes it.
- tests/test_tidal_stream_tier_verification.py — ten tests covering
  match / upgrade / downgrade / unknown / defensive paths.
- tests/test_tidal_search_shortening.py — fake `Quality` values
  brought in line with tidalapi's real values so both files share
  a consistent stub regardless of pytest collection order.
- webui/static/helper.js — WHATS_NEW entry under 2.40 describing
  the rank-based tier comparison.

Reported on Discord by Netti93 — the "same account works via
Tidarr" comparison narrowed the cause to SoulSync's download path
rather than an account/region issue.
2026-04-24 13:12:30 -07:00
..
repair_jobs Add $cdnum template variable for multi-disc filenames 2026-04-21 22:55:37 -07:00
acoustid_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
acoustid_verification.py Fix silent wrong-artist track downloads (Maduk/Tom Walker bug) 2026-04-22 10:32:55 -07:00
album_consistency.py Picard-style release preference scoring for MusicBrainz matching 2026-04-16 13:29:04 -07:00
api_call_tracker.py Make ApiCallTracker.save() atomic to prevent corrupt history files 2026-04-22 22:15:09 -07:00
artist_source_detail.py Extract _build_source_only_artist_detail into core/artist_source_detail.py 2026-04-23 08:09:29 -07:00
artist_source_lookup.py Make artist_name Optional in find_library_artist_for_source 2026-04-22 22:15:03 -07:00
audiodb_client.py Add API Rate Monitor dashboard with real-time speedometer gauges 2026-04-02 10:46:43 -07:00
audiodb_worker.py Add genre whitelist for filtering junk tags during enrichment 2026-04-18 20:23:53 -07:00
auto_import_worker.py Use parent folder name as artist override in auto-import 2026-04-20 22:56:34 -07:00
automation_engine.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
database_update_worker.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
deezer_client.py Fix playlist cover art not syncing to Plex/Jellyfin from Deezer and other sources 2026-04-15 10:25:38 -07:00
deezer_download_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
deezer_worker.py Add genre whitelist for filtering junk tags during enrichment 2026-04-18 20:23:53 -07:00
discogs_client.py Fix Discogs cache — add field extractor, wire worker caching, browser UI 2026-04-02 19:47:36 -07:00
discogs_worker.py Add genre whitelist for filtering junk tags during enrichment 2026-04-18 20:23:53 -07:00
download_orchestrator.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
genius_client.py Add API Rate Monitor dashboard with real-time speedometer gauges 2026-04-02 10:46:43 -07:00
genius_worker.py Improve graceful shutdown and rollback safety 2026-04-12 15:17:18 +03:00
genre_filter.py Expand default genre whitelist from 223 to 272 genres 2026-04-18 20:32:49 -07:00
hifi_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
hydrabase_client.py Add missing _get_artist_image_from_albums to HydrabaseClient 2026-03-23 17:54:21 -07:00
hydrabase_worker.py Improve graceful shutdown and rollback safety 2026-04-12 15:17:18 +03:00
itunes_client.py Fix iTunes album tracks failing on region-restricted releases 2026-04-20 11:10:54 -07:00
itunes_worker.py Add genre whitelist for filtering junk tags during enrichment 2026-04-18 20:23:53 -07:00
jellyfin_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
lastfm_client.py Add Last.fm Track Radio to Discover page 2026-04-14 23:41:36 -07:00
lastfm_worker.py fix: stop enrichment workers from re-processing rows forever 2026-04-19 15:22:24 -07:00
lidarr_download_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
listenbrainz_client.py Remove emojis from all Python log and print statements 2026-04-11 21:11:02 -07:00
listenbrainz_manager.py Add Last.fm Track Radio to Discover page 2026-04-14 23:41:36 -07:00
listening_stats_worker.py fix: batch N+1 queries in listening stats worker 2026-04-19 15:22:25 -07:00
lyrics_client.py Remove emojis from all Python log and print statements 2026-04-11 21:11:02 -07:00
matching_engine.py Replace more print logs with proper logger usage 2026-04-21 14:42:21 +03:00
metadata_cache.py fix: batch metadata cache entity lookups 2026-04-19 15:22:24 -07:00
metadata_service.py MusicBrainz: Fix artist images, total_tracks off-by-one, and Artist+Title queries 2026-04-24 10:17:59 -07:00
musicbrainz_client.py MusicBrainz: Resolve release-group MBIDs to a release on album click 2026-04-24 08:48:02 -07:00
musicbrainz_search.py MusicBrainz: Dedupe same-named homonyms in artist search results 2026-04-24 10:27:45 -07:00
musicbrainz_service.py Add metadata cache maintenance and health monitoring 2026-03-30 07:40:20 -07:00
musicbrainz_worker.py fix: stop enrichment workers from re-processing rows forever 2026-04-19 15:22:24 -07:00
navidrome_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
personalized_playlists.py Replace all legacy metadata_service imports with canonical functions 2026-04-10 12:52:36 -07:00
plex_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
qobuz_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
qobuz_worker.py fix: stop enrichment workers from re-processing rows forever 2026-04-19 15:22:24 -07:00
repair_worker.py Rebuild Discography Backfill: auto-wishlist, Fix All, section UI 2026-04-21 18:44:43 -07:00
replaygain.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
seasonal_discovery.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
soulid_worker.py Tighten legacy logging output 2026-04-21 18:00:31 +03:00
soulseek_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
soulsync_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
spotify_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
spotify_public_scraper.py Add Spotify Link tab for public playlist/album scraping without API credentials 2026-03-15 21:25:05 -07:00
spotify_worker.py Add genre whitelist for filtering junk tags during enrichment 2026-04-18 20:23:53 -07:00
tag_writer.py Separate AcoustID file logging 2026-04-21 18:00:31 +03:00
tidal_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
tidal_download_client.py Reject Tidal streams that silently downgrade from the requested quality 2026-04-24 13:12:30 -07:00
tidal_worker.py fix: stop enrichment workers from re-processing rows forever 2026-04-19 15:22:24 -07:00
watchlist_scanner.py Fix watchlist content filters: live false positives + auto-scan bypass 2026-04-21 19:16:25 -07:00
web_scan_manager.py Add SoulSync standalone library — no media server required 2026-04-17 20:34:55 -07:00
wishlist_service.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
worker_utils.py Improve graceful shutdown and rollback safety 2026-04-12 15:17:18 +03:00
youtube_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00