soulsync/tests/imports
Broque Thomas 997732ee63 Wishlist: fix three regressions causing all imports to land as track 01 with no year
Real-world regression triggered by the album-bundle work earlier in
2.6.3. Tracks with full Spotify metadata were importing as
``01 - <title>`` under ``Artist - Album/`` (no year), even when the
source filename carried the correct track number and Spotify's
release_date was available.

Investigation via DB inspection of stored wishlist rows:

```
"Never Gonna Give You Up" → track_number=None,  release_date=""
"idfc"                    → track_number=1,    release_date=""
"No Sleep Till Brooklyn"  → track_number=1,    release_date=""
```

Source-of-truth Spotify metadata had release_date AND real track
positions, but the wishlist row was poisoned. Three regressions
compounded the loss:

**Fix A — ``track_object_to_dict`` (``core/wishlist/payloads.py:295``)
preserved only album.name during Track→dict conversion.**

Pre-fix:
```python
album_name = "Unknown Album"
if hasattr(track_object, "album") and track_object.album:
    if hasattr(track_object.album, "name"):
        album_name = track_object.album.name
    else:
        album_name = str(track_object.album)

result = {
    ...
    "album": {"name": album_name},   # ← release_date / images / etc. all dropped
    ...
}
```

When a wishlist payload arrived as a Track dataclass instead of a
raw spotify_data dict, the Track→dict conversion stripped
release_date, images, album_type, total_tracks, id, and album-level
artists. Every wishlist row added through this path landed in the
DB with ``album={'name': X}`` only.

Post-fix: three branches handle the three album shapes
- ``album_attr`` is a dict → ``dict(album_attr)`` preserves every key
- ``album_attr`` is a sub-object → pull all common Album-dataclass
  attrs (id, release_date, album_type, total_tracks, images, ...)
- ``album_attr`` is a bare string → build a dict from the track
  object's adjacent attrs (release_date, album_id, album_type, ...)
  and surface ``image_url`` as ``album.images``

**Fix B — ``core/discovery/playlist.py:309`` only added
``track_number`` / ``disc_number`` keys when truthy.**

Pre-fix:
```python
matched_data = { 'id': ..., 'name': ..., ... }   # no track_number / disc_number
if track_number:
    matched_data['track_number'] = track_number
if disc_number:
    matched_data['disc_number'] = disc_number
```

Deezer-sourced matches always hit this branch with ``track_number=None``
because the cache enrichment at line 304 reads ``_raw.get('track_number')``
literally, but Deezer's raw shape uses ``track_position``. So the key
was omitted from ``matched_data``, downstream consumers couldn't
distinguish "missing key" from "value is 1", and the chain silently
filled 1.

Post-fix: keys are ALWAYS present (None when unknown). Also adds a
``best_match.track_number`` fallback so the Track-dataclass-mapped
value (which DOES include ``track_position``→``track_number``
mapping) gets used when the cache lookup misses.

**Fix C — Pipeline only consulted ``album_info.track_number`` before
falling to the filename (``core/imports/pipeline.py:645``).**

VA-collection source files like ``417 Fountains of Wayne - Stacys
Mom.flac`` have a leading playlist-position number that isn't the
album track number. The previous chain (album_info → filename →
floor-1) couldn't recover the real position because the filename
extractor either returned 417 (wrong) or None (caught by the floor).
But the wishlist payload's ``track_info.spotify_data.track_number``
HAD the right answer all along — Spotify says Stacy's Mom is track
3 on Welcome Interstate Managers.

Post-fix: resolution chain extracted into ``core/imports/track_number.py:resolve_track_number``
as a pure function:
1. ``album_info.track_number`` (album-bundle dispatch authoritative)
2. ``track_info.track_number`` (per-track flow payload)
3. ``track_info.spotify_data.track_number`` (nested fallback)
4. ``extract_explicit_track_number(file_path)`` (filename, returns
   0 when no numeric prefix — vs the default helper that returns 1)
5. Caller (pipeline) applies the final >=1 floor

Each step coerces to a positive int or falls through to the next.
Pure function = unit-testable in isolation = single place to fix
the rule.

**Test coverage (37 new tests):**

- ``tests/wishlist/test_payloads.py`` (+4) — Track→dict conversion
  preserves full album dict (dict / object / string album shapes) +
  None-track-number stays None.
- ``tests/discovery/test_discovery_playlist.py`` (+2) — matched_data
  always includes track_number/disc_number keys (None when unknown)
  + falls back to best_match attrs when cache misses.
- ``tests/imports/test_track_number_resolver.py`` (+16) — every
  resolution-chain branch pinned: album_info-wins, track_info
  fallback, spotify_data nested, JSON-string parsing, garbage-string
  fall-through, zero / negative / non-numeric / string-numeric
  coercion, filename fallback, explicit extractor vs default
  extractor semantics, defensive None inputs, VA-collection
  filename behaviour, all-sources-missing → None.

1571 wider-suite tests pass (wishlist + imports + discovery +
downloads + metadata). Ruff clean.

**Migration note:** existing wishlist rows that were saved under
the OLD ``track_object_to_dict`` (with stripped album metadata) still
have ``release_date=''`` in the DB blob. Those won't self-heal — the
next attempt loads from the poisoned blob. Users can remove + re-add
those tracks to refresh, or wait for the next sync run that
re-discovers them with full metadata. No automatic migration shipped
in this PR (scope creep — the forward path is fixed, backfill is a
separate concern).
2026-05-27 15:39:22 -07:00
..
test_album_matching_exact_id.py Auto-import matching: fix Deezer source classification + bump tolerance 2026-05-09 15:53:17 -07:00
test_album_matching_helper.py Auto-import polish: real-file tag reader test, source-aware duration, pin consolation 2026-05-09 11:08:09 -07:00
test_album_search_scoring.py Auto-import: fall through to other metadata sources when primary returns no match 2026-05-12 12:32:18 -07:00
test_auto_import_clear_completed_endpoint.py Import history: Clear History button now sweeps stuck 'processing' rows 2026-05-12 12:53:37 -07:00
test_auto_import_context_shape.py Auto-import: fall through to other metadata sources when primary returns no match 2026-05-12 12:32:18 -07:00
test_auto_import_executor.py Auto-import: bounded ThreadPoolExecutor + per-candidate UI state isolation 2026-05-09 17:45:42 -07:00
test_auto_import_live_progress.py Auto-import: live per-track progress + in-progress history row 2026-05-02 22:34:09 -07:00
test_auto_import_multi_disc_matching.py Auto-import: respect disc_number in dedup + match scoring 2026-05-08 22:36:51 -07:00
test_auto_import_multi_source_fallback.py Auto-import: fall through to other metadata sources when primary returns no match 2026-05-12 12:32:18 -07:00
test_auto_import_scanner_grouping.py Auto-import: dedup on folder_hash, not path — fixes silent-skip bug 2026-05-09 14:09:19 -07:00
test_auto_import_tag_reader_real_files.py Auto-import polish: real-file tag reader test, source-aware duration, pin consolation 2026-05-09 11:08:09 -07:00
test_build_single_import_context_typed_path.py Migrate album-info builders to typed Album path 2026-05-03 22:53:12 -07:00
test_dedup_orphan_cleanup.py Prune slskd dedup orphans after import 2026-05-01 09:35:08 -07:00
test_duration_tolerance_resolution.py Configurable duration tolerance for downloaded-file integrity check 2026-05-14 06:53:36 -07:00
test_file_integrity.py Reject broken downloads before tagging via universal integrity check 2026-05-03 08:21:01 -07:00
test_import_album.py Rehome import helpers into core/imports 2026-04-27 19:54:44 +03:00
test_import_context.py Fix Spotify source ID fallback 2026-05-02 22:02:01 +03:00
test_import_file_ops.py Fix album-bundle downloads landing every track as track 1 2026-05-26 21:04:27 -07:00
test_import_filename.py Rehome import helpers into core/imports 2026-04-27 19:54:44 +03:00
test_import_guards.py Rehome import helpers into core/imports 2026-04-27 19:54:44 +03:00
test_import_paths.py fix: substitute \$cdnum in download paths and skip auto disc folder when template uses it 2026-04-28 21:32:24 -07:00
test_import_pipeline.py Reject broken downloads before tagging via universal integrity check 2026-05-03 08:21:01 -07:00
test_import_resolution_single_track_context.py Move metadata helpers into package modules 2026-04-29 11:28:42 +03:00
test_import_routes.py Surface metadata source on Import album results (#681) 2026-05-23 16:22:17 -07:00
test_import_side_effects.py Add torrent and usenet release staging support 2026-05-21 14:22:21 -07:00
test_import_singles_route_through_album_path.py Route imported singles/EPs through album_path template 2026-04-30 21:33:09 -07:00
test_import_staging.py Show MusicBrainz release variants in import 2026-05-24 09:33:19 -07:00
test_lossy_copy_delete_original.py Honor lossy_copy.delete_original after successful conversion 2026-05-02 14:26:46 -07:00
test_quarantine_management.py fix(quarantine): drop already-quarantined sources from candidate picker (#652) 2026-05-19 21:19:50 -07:00
test_search_match_endpoints.py Deezer search: drop advanced-syntax at endpoint, free-text + rerank wins 2026-05-10 09:36:48 -07:00
test_track_number_resolver.py Wishlist: fix three regressions causing all imports to land as track 01 with no year 2026-05-27 15:39:22 -07:00