Commit graph

13 commits

Author SHA1 Message Date
BoulderBadgeDad
4af7600fd5 lossy copy: support all lossless formats, not just FLAC (#941)
radoslav-orlov: "create lossy copies of lossless tracks" only recognized FLAC, even though ALAC/
WAV/AIFF/DSD are now quality-profile formats. the FLAC knowledge was hardcoded in 3 separate
places (the import path, the Lossy Converter scan, and the fix executor) — exactly how a format
gets added in one spot but not another.

kettui-style fix — one canonical seam both sites route through, instead of 3 more string edits:

- new core/quality/lossless.py: is_lossless_format / is_lossless_audio_path (pure; injects a
  codec probe for the ambiguous .m4a/.mp4 — ALAC vs AAC — so the decision stays testable with no
  I/O), LOSSLESS_FORMATS (single source of truth, derived-consistent with model.tier_score), and
  the lossy_output_would_overwrite_source safety invariant.
- create_lossy_copy + the Lossy Converter scan + repair_worker._fix_missing_lossy_copy all route
  through it. SQL pre-filters by candidate extensions, then each file is confirmed (probing .m4a).
- SAFETY: a lossy copy must never be written over its own source — an .m4a ALAC source + AAC
  target lands on the same .m4a path, and ffmpeg runs with -y. all three sites now bail on the
  overwrite case BEFORE ffmpeg (the existing delete-original guard was too late — the source was
  already clobbered). dropped a vestigial mutagen FLAC import; updated FLAC-only UI strings.

19 tests: full seam coverage (formats, the .m4a ALAC/AAC probe branch, candidate extensions, the
overwrite guard), a tier-model consistency test that fails if the lossless set drifts, and import-
site wiring tests — WAV now converts (was rejected), and the .m4a-ALAC+AAC overwrite case proves
ffmpeg NEVER runs. 286 quality/import/repair tests green, ruff clean.
2026-06-28 12:13:05 -07:00
BoulderBadgeDad
b62d9b5b08 quality: recognize DSD (.dsf/.dff) as lossless + stop the false "truncated" flag (#939)
diegocade1: DSD files (.dsf, ~500MB DSD64) were labeled "Low Quality" and nagged to upgrade.
two independent causes, both fixed (additive — no existing format/behaviour changed):

1) DSF was an unrecognized format -> bottom 'unknown' tier -> "Low Quality":
   - source_map: map .dsf/.dff -> 'dsf' (also lights it up in AUDIO_EXTENSIONS, so Soulseek can
     match a DSF if one exists)
   - model.tier_score: 'dsf' base 102 (just above FLAC) — lands in the lossless range
   - probe_audio_quality: add a DSD branch returning format='dsf' (mutagen.dsf for .dsf detail;
     .dff classifies lossless without measured detail) instead of None
   - settings UI: DSD in RT_LOSSLESS_FORMATS + a "DSD (DSF / DFF)" option in the profile dropdown

2) the actual cause of the screenshot's findings — the truncation guard falsely called DSF
   "broken (only ~12% decodes)": ffmpeg decodes DSD to PCM at a different rate than the DSD
   container's 2.8 MHz, so astats samples ÷ container-rate massively under-counts. now
   detect_broken_audio skips the truncation check for DSD (silence detection still applies).

8 seam tests: dsf/dff -> 'dsf'; dsf tier in lossless range (with + without measured bitrate);
is_dsd_path; and a contrast pair proving the same 12%-decode numbers flag a .flac but skip a
.dsf. 230 quality/import/silence tests green, ruff + JS integrity clean.
2026-06-28 11:45:16 -07:00
BoulderBadgeDad
ed0a2079cf
Merge pull request #896 from nick2000713/feature/best-quality-search-mode
Global quality system: real-audio verification, best-quality search & quality profiles (please try...not ready to merge)
2026-06-24 20:27:38 -07:00
dev
774d0f6c65 feat(quality): make every audio format controllable via ranked targets
The ranked-target list is now the single source of truth for which formats
download, in the user's exact priority order, for ALL sources — no hardcoded
format hierarchy decides anything. A candidate passes only if it matches a
ranked target; if nothing matches, the existing Use-Fallback toggle decides.

- source_map: new shared format_from_extension() + AUDIO_EXTENSIONS — one
  source of truth for extension→format used by every extension-based source, so
  adding a format lights it up everywhere. Soulseek now classifies through it
  (opus/wav/aiff were previously dropped as 'unknown').
- file_ops.probe_audio_quality (generic import-time guard, all sources): add
  WMA; detect ALAC from the real codec (an .m4a is AAC or ALAC).
- soulseek: drop the AAC-specific opt-in gate — AAC now follows the same
  universal rule as every format.
- model.tier_score: documented as ONLY a same-format tiebreak + fallback order,
  never cross-format priority (the list owns that); add opus/alac bases.
- UI: ranked-target editor offers all formats (FLAC/ALAC/WAV·AIFF lossless with
  bit-depth+sample-rate; MP3/AAC/OGG/Opus/WMA lossy with min-bitrate).
- tests: AAC retargeted to the universal model; new coverage for
  format_from_extension and matches_target across all formats.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:01:20 +02:00
dev
17137cea5b fix(quality): .aiff probe class + refresh tests drifted by the 2.7.4 merge (#896)
- file_ops.probe_audio_quality: .aiff/.aif were opened with mutagen.wave.WAVE,
  which can't parse AIFF — it raised, failed open, and let AIFF silently bypass
  the quality filter. Route aiff/aif to mutagen.aiff.AIFF (still the 'wav'
  lossless tier).
- test_hifi_preview_guard: _get_hls_manifest gained an expected_duration_s kwarg
  and the start tier now comes from quality_tier_for_source (default profile ->
  'hires'); accept the kwarg and pin the tier so the chain is deterministic.
- test_quarantine_management: quarantine_group_key intentionally no longer uses
  source-specific ids/uri (they break cross-batch sibling matching); assert the
  isrc -> normalized-name contract instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:40:14 +02:00
BoulderBadgeDad
9d091207f6 fix: atomic file placement so Jellyfin can't index a half-written track (null-disc)
safe_move_file used shutil.move, which for a CROSS-filesystem move (downloads volume ->
library volume, common in Docker/NAS) copies the file to the FINAL path incrementally. A
media-server real-time watcher (Jellyfin) can catch that partial file mid-write and cache it
with null/incomplete metadata — tracks landing with no disc. Inconsistent because it only bites
cross-fs and races the scan tick; 're-add library' fixes it (rescan reads the now-complete file)
and the on-disk tags are fine — exactly the reported symptoms.

Fix: same-fs uses an atomic os.replace (also overwrites dst); cross-fs copies to a HIDDEN temp
sibling, fsyncs, then atomic os.replace into place (+ temp cleanup on failure). A watcher only
ever sees the COMPLETE file. EXDEV/EPERM/EACCES + the old string check route here, so detection
is strictly broader than before.

Tests: same-fs move, simulated EXDEV routes to the atomic path and leaves no partial temp, helper
completes+cleans, helper cleans temp + preserves source on failure. Existing replace-destination
test still green; 574 imports+relocate tests pass.
2026-06-23 23:17:50 -07:00
dev
11d2fa9ad6 feat(ui): show sample rate in the audio quality string (FLAC 24bit/96kHz)
get_audio_quality_string now appends the FLAC sample rate so the Downloads
quality chip and library history read e.g. 'FLAC 24bit/96kHz' instead of just
'FLAC 24bit' — surfaces hi-res frequency (44.1/48/96/192kHz) at a glance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:30:06 +02:00
nick2000713
2c91af0062 feat: global AudioQuality model with post-download quarantine + retry
Replaces the Soulseek-only bit-depth heuristic with a source-agnostic
quality system that works across all download sources.

## core/quality/model.py (new)
- AudioQuality dataclass: format, bitrate, sample_rate, bit_depth
- QualityTarget: one ranked entry in the user's priority list
- filter_and_rank(): source-neutral candidate ranking
- rank_candidate(): scores any AudioQuality against ranked_targets
- v2_qualities_to_ranked_targets(): migration helper

## core/download_plugins/types.py
- SearchResult gains sample_rate + bit_depth fields
- audio_quality property returns unified AudioQuality
- AlbumResult gets audio_quality aggregated from tracks

## core/soulseek_client.py
- Parses slskd attributes array (type 4=sample_rate, type 5=bit_depth)
- Real values instead of kbps heuristic
- filter_results_by_quality_preference() replaced by filter_and_rank()

## database/music_database.py
- Quality profile v3 with ranked_targets list
- Auto-migration v2 → v3 on load
- Presets (audiophile/balanced/space_saver) updated to v3

## core/imports/file_ops.py
- probe_audio_quality(): reads actual downloaded file via mutagen
  returns AudioQuality with ground-truth values

## core/imports/guards.py
- check_quality_target(): replaces check_flac_bit_depth
  checks all formats/sources against ranked_targets
- check_flac_bit_depth() kept as backwards-compat wrapper

## core/imports/pipeline.py
- Uses check_quality_target() instead of check_flac_bit_depth()
- Quality mismatch triggers _requeue_quarantined_task_for_retry('quality')
  so next-best candidate is tried before failing (same as AcoustID)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 22:22:08 +02:00
Broque Thomas
aa54bed818 Surface silent exceptions across remaining modules — ~70 sites
Final sweep. Covers:
- Downloads: candidates / lifecycle / master / monitor / wishlist_failed
- Metadata: source / registry / cache / common / artwork (+ plex_client)
- Imports: pipeline / resolution / file_ops / paths / guards
- Library: path_resolver / retag / duplicate_cleaner
- Stats / playlists / wishlist / discovery / automation / enrichment
- Misc: hydrabase_client, soulsync_client, tag_writer, debug_info,
  api_call_tracker, album_consistency, beatport_unified_scraper,
  reorganize_runner, seasonal_discovery, lidarr_download_client,
  services/sync_service.py, automation_engine, automation/progress

Two `_e` renames in imports/file_ops.py (outer scope binding `e`).
A few finally-block sites in metadata/album_mbid_cache.py,
library/track_identity.py, listening_stats_worker.py, watchlist/
auto_scan.py left silent — same reason as the rest of the sweep
(logger calls during cleanup paths can themselves raise).

Refs #369
2026-05-07 10:28:58 -07:00
Broque Thomas
486116c34f Honor lossy_copy.delete_original after successful conversion
Reported case (CAL): with lossy_copy.enabled=True,
lossy_copy.delete_original=True, and codec=mp3, every download left
both the original FLAC AND the converted MP3 in the target folder.
Users opting into a lossy-only library ended up dual-format on
every import.

Root cause: ``core/imports/file_ops.py:create_lossy_copy`` reads
``lossy_copy.codec`` and ``lossy_copy.bitrate`` from config but never
reads ``lossy_copy.delete_original``. The setting is only consulted
by the pre-move source-vanished check at
``core/imports/pipeline.py:651`` (so the pipeline knows to look for
a lossy variant when the FLAC has already moved on), but no code
path actually deletes the source after conversion.

Fix: after ffmpeg returns success and the QUALITY tag is written,
check ``lossy_copy.delete_original`` and ``os.remove`` the original
when enabled. Belt-and-suspenders:

- Same-path guard (``os.path.normpath(out_path) != os.path.normpath(final_path)``)
  prevents accidentally wiping the just-converted file if a future
  codec choice somehow resolves out_path to the source path.
- ``FileNotFoundError`` is treated as success (concurrent worker /
  dedup cleanup got there first).
- Other ``OSError`` (permission denied, locked file) is logged but
  doesn't propagate — the conversion already succeeded, the user just
  has to clean up the original manually.

Failure paths skip the delete:
- ffmpeg returns non-zero → returns None, original stays
- lossy_copy.enabled=False → early return before conversion runs
- delete_original=False (default) → original stays

7 regression tests cover honored-when-enabled, kept-when-disabled,
default-keep, ffmpeg-failure-path, lossy-disabled-path, racing-delete,
and locked-file paths. Full pytest 1563 passed; ruff clean.

Note: this PR does NOT address the second bug CAL mentioned (track
re-downloaded despite already existing on disk). That symptom is
caused by stale album metadata on the user's existing files — the
library DB has the track tagged on a different album than the
metadata source reports — combined with wishlist.allow_duplicate_tracks
defaulting to True. Same class of issue partially addressed in PR
fix/watchlist-redownload-and-duplicate-detection but compilation-
album drift is the only currently-handled case. Tracking separately.
2026-05-02 14:26:46 -07:00
Broque Thomas
46d8e15674 Prune slskd dedup orphans after import
slskd appends "_<19-digit unix-nanosecond timestamp>" to a downloaded
filename when the destination already contains a same-named file
(concurrent downloads of the same track, partial-file retries after a
connection drop, cancelled-then-redownloaded files, the same track
surfacing in multiple synced playlists). The file-finder code already
recognized the suffix when matching a download to its source — but
after the canonical file moved into the library, the leftover
"_<timestamp>" siblings sat orphaned in the downloads folder forever.

Reported on Discord by Shdjfgatdif.

cleanup_slskd_dedup_siblings() runs at the end of each successful
import (3 safe_move_file sites in pipeline.py) and prunes any
remaining siblings that strip down to the canonical stem with the
same extension. Conservative match (>= 18 trailing digits) keeps
legitimate filenames like "Track 5" and "Album 1995" untouched. Per-
file unlink failures are swallowed so a single locked file doesn't
block the rest.

17 regression tests cover the suffix-strip primitive, orphan removal,
no-op cases, mismatched extensions, subdirectories, and partial-failure
recovery.
2026-05-01 09:35:08 -07:00
Antti Kettunen
02305096a3
Tighten metadata and import safety
- Normalize album import track display handling so queue labels and match rows stay consistent
- Bound MusicBrainz caches and avoid caching transient lookup failures
- Stop swallowing programmer errors in source enrichment helpers
- Restore import config test seams without reintroducing lazy imports
- Guard task completion calls and fix the Windows path test expectation
- Keep file lock tracking from growing without bound
2026-04-27 20:28:05 +03:00
Antti Kettunen
e10df4caf2
Rehome import helpers into core/imports
- Move import flow modules into a dedicated package
- Update app and test imports to the new namespace
- Group the import-focused tests under tests/imports
2026-04-27 19:54:44 +03:00
Renamed from core/import_file_ops.py (Browse further)