two field reports: 1) FIX-ALL skipped these findings — bulk_fix_findings() has a hardcoded fixable_types allowlist that didn't include 'short_preview_track'. added it, so select-all/fix-all and the per-page bulk-fix now cover this tool. 2) RE-WISHLISTED ITEM WAS ART-LESS — the payload pulled album art from the library thumb, which is empty for un-enriched HiFi previews, so the wishlist orb showed initials (no album OR artist image, since the orb falls back to album art). now the duration lookup also captures the metadata source's CDN art from raw_data (spotify album.images / itunes artworkUrl, upscaled) and stores it on the finding; the fix prefers that over the empty thumb. 3 new tests (art capture from spotify raw_data, itunes artwork upscale, fix uses finding art); 8 job tests + repair suite green.
191 lines
7.7 KiB
Python
191 lines
7.7 KiB
Python
"""Preview-clip cleanup job (#937-adjacent): flag ~30s preview clips whose source says the
|
|
real track is much longer, then on approval delete the file + drop the row + re-wishlist."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from core.repair_jobs.base import JobContext
|
|
from core.repair_jobs.short_preview_track import ShortPreviewTrackJob
|
|
from core.repair_worker import RepairWorker
|
|
from database.music_database import MusicDatabase
|
|
|
|
|
|
def _seed(db: MusicDatabase):
|
|
conn = db._get_connection()
|
|
conn.execute("INSERT OR IGNORE INTO artists (id, name) VALUES ('ar1', 'A-ha')")
|
|
conn.execute("INSERT INTO albums (id, artist_id, title) VALUES ('al1', 'ar1', 'Hunting High and Low')")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def _track(db, tid: int, duration_ms, path, spotify_id=None):
|
|
# tid is an INTEGER id, exactly like production (tracks.id is INTEGER PRIMARY KEY) — so the
|
|
# test exercises the real round-trip: the finding stores str(id) and the fix queries WHERE id=?.
|
|
conn = db._get_connection()
|
|
conn.execute(
|
|
"INSERT INTO tracks (id, artist_id, album_id, title, duration, file_path, spotify_track_id) "
|
|
"VALUES (?, 'ar1', 'al1', ?, ?, ?, ?)",
|
|
(tid, f"Track {tid}", duration_ms, path, spotify_id),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
class _FakeSpotify:
|
|
"""get_track_details(id) -> {'duration_ms': N}. 'sp_long' is a full song; else short."""
|
|
def get_track_details(self, track_id, **_):
|
|
return {'duration_ms': 200_000} if track_id == 'sp_long' else {'duration_ms': 28_000}
|
|
|
|
|
|
def _ctx(db, findings, spotify=None):
|
|
return JobContext(
|
|
db=db, transfer_folder='/tmp', config_manager=None,
|
|
spotify_client=spotify,
|
|
create_finding=lambda **kw: findings.append(kw) or True,
|
|
should_stop=lambda: False, is_paused=lambda: False,
|
|
)
|
|
|
|
|
|
# ── scan ──
|
|
|
|
def test_scan_flags_preview_skips_genuine_short_and_unverifiable(tmp_path: Path):
|
|
db = MusicDatabase(str(tmp_path / 'm.db'))
|
|
_seed(db)
|
|
_track(db, 1, 28_000, '/m/p.flac', spotify_id='sp_long') # id 1: 28s file, source 200s → FLAG
|
|
_track(db, 2, 28_000, '/m/i.flac', spotify_id='sp_short') # id 2: 28s file, source 28s → skip (genuine)
|
|
_track(db, 3, 28_000, '/m/m.flac', spotify_id=None) # id 3: 28s, no source id → skip (unverifiable)
|
|
_track(db, 4, 200_000, '/m/l.flac', spotify_id='sp_long') # id 4: 200s → not scanned (>30s)
|
|
|
|
findings = []
|
|
result = ShortPreviewTrackJob().scan(_ctx(db, findings, _FakeSpotify()))
|
|
|
|
assert len(findings) == 1
|
|
f = findings[0]
|
|
assert f['finding_type'] == 'short_preview_track'
|
|
assert f['entity_id'] == '1' # str(int id), as create_finding stores it
|
|
assert f['entity_type'] == 'track'
|
|
assert f['details']['expected_duration_s'] == pytest.approx(200.0)
|
|
assert result.findings_created == 1
|
|
assert result.scanned == 3 # the 200s track is excluded by the query, not scanned
|
|
assert result.skipped == 2 # skit + noid
|
|
|
|
|
|
def test_scan_creates_no_finding_when_source_agrees_short(tmp_path: Path):
|
|
db = MusicDatabase(str(tmp_path / 'm.db'))
|
|
_seed(db)
|
|
_track(db, 1, 28_000, '/m/i.flac', spotify_id='sp_short') # source also says 28s
|
|
findings = []
|
|
ShortPreviewTrackJob().scan(_ctx(db, findings, _FakeSpotify()))
|
|
assert findings == []
|
|
|
|
|
|
def test_estimate_scope_counts_short_tracks(tmp_path: Path):
|
|
db = MusicDatabase(str(tmp_path / 'm.db'))
|
|
_seed(db)
|
|
_track(db, 1, 28_000, '/m/a.flac', spotify_id='sp_long')
|
|
_track(db, 2, 10_000, '/m/b.flac', spotify_id='sp_short')
|
|
_track(db, 3, 200_000, '/m/c.flac', spotify_id='sp_long') # >30s, excluded
|
|
assert ShortPreviewTrackJob().estimate_scope(_ctx(db, [], _FakeSpotify())) == 2
|
|
|
|
|
|
# ── fix (approval) ──
|
|
|
|
def test_fix_deletes_file_removes_row_and_wishlists(tmp_path: Path):
|
|
db = MusicDatabase(str(tmp_path / 'm.db'))
|
|
_seed(db)
|
|
preview = tmp_path / 'preview.flac'
|
|
preview.write_bytes(b'fake audio bytes')
|
|
_track(db, 1, 28_000, str(preview), spotify_id='sp1')
|
|
|
|
captured = {}
|
|
db.add_to_wishlist = lambda spotify_track_data, **kw: captured.update(
|
|
{'data': spotify_track_data, 'kw': kw}) or True
|
|
|
|
w = RepairWorker.__new__(RepairWorker)
|
|
w.db = db
|
|
w.transfer_folder = str(tmp_path)
|
|
w._config_manager = None
|
|
|
|
res = w._fix_short_preview_track(
|
|
'track', '1', str(preview), # entity_id is the string the finding stored
|
|
{'expected_duration_s': 225.0, 'original_path': str(preview)})
|
|
|
|
assert res['success'] is True
|
|
assert not preview.exists() # preview file deleted
|
|
assert captured['data']['name'] == 'Track 1' # re-wishlisted with payload
|
|
assert captured['data']['duration_ms'] == 225_000 # uses the real (expected) length
|
|
assert captured['kw'].get('source_type') == 'redownload'
|
|
conn = db._get_connection()
|
|
remaining = conn.execute("SELECT COUNT(*) FROM tracks WHERE id=1").fetchone()[0]
|
|
conn.close()
|
|
assert remaining == 0 # DB row dropped → track missing again
|
|
|
|
|
|
# ── album art capture (so the re-wishlisted item isn't art-less) ──
|
|
|
|
def test_scan_captures_source_album_art_into_finding(tmp_path: Path):
|
|
"""The duration lookup's raw_data carries the source CDN album art — capture it so the
|
|
re-wishlist isn't art-less when the library thumb is empty (the reported bug)."""
|
|
db = MusicDatabase(str(tmp_path / 'm.db'))
|
|
_seed(db)
|
|
_track(db, 1, 28_000, '/m/p.flac', spotify_id='sp_long')
|
|
|
|
class _SpWithArt:
|
|
def get_track_details(self, track_id, **_):
|
|
return {'duration_ms': 200_000,
|
|
'raw_data': {'album': {'images': [{'url': 'https://cdn/cover.jpg'}]}}}
|
|
|
|
findings = []
|
|
ShortPreviewTrackJob().scan(_ctx(db, findings, _SpWithArt()))
|
|
assert len(findings) == 1
|
|
assert findings[0]['details']['album_thumb_url'] == 'https://cdn/cover.jpg'
|
|
|
|
|
|
def test_art_from_itunes_artwork_is_upscaled():
|
|
from core.repair_jobs.short_preview_track import _art_from_details
|
|
d = {'raw_data': {'artworkUrl100': 'https://is1.mzstatic.com/a/100x100bb.jpg'}}
|
|
assert _art_from_details(d) == 'https://is1.mzstatic.com/a/600x600bb.jpg'
|
|
|
|
|
|
def test_fix_uses_finding_art_for_wishlist_payload(tmp_path: Path):
|
|
db = MusicDatabase(str(tmp_path / 'm.db'))
|
|
_seed(db)
|
|
preview = tmp_path / 'preview.flac'
|
|
preview.write_bytes(b'fake audio')
|
|
_track(db, 1, 28_000, str(preview), spotify_id='sp1')
|
|
|
|
captured = {}
|
|
db.add_to_wishlist = lambda spotify_track_data, **kw: captured.update(
|
|
{'data': spotify_track_data}) or True
|
|
|
|
w = RepairWorker.__new__(RepairWorker)
|
|
w.db = db
|
|
w.transfer_folder = str(tmp_path)
|
|
w._config_manager = None
|
|
|
|
w._fix_short_preview_track('track', '1', str(preview),
|
|
{'expected_duration_s': 200.0,
|
|
'album_thumb_url': 'https://cdn/art.jpg'})
|
|
assert captured['data']['album']['images'] == [{'url': 'https://cdn/art.jpg'}]
|
|
|
|
|
|
def test_fix_missing_file_still_wishlists_and_drops_row(tmp_path: Path):
|
|
"""If the preview file is already gone, still re-wishlist + drop the row (idempotent-ish)."""
|
|
db = MusicDatabase(str(tmp_path / 'm.db'))
|
|
_seed(db)
|
|
_track(db, 1, 28_000, str(tmp_path / 'gone.flac'), spotify_id='sp2')
|
|
db.add_to_wishlist = lambda spotify_track_data, **kw: True
|
|
|
|
w = RepairWorker.__new__(RepairWorker)
|
|
w.db = db
|
|
w.transfer_folder = str(tmp_path)
|
|
w._config_manager = None
|
|
|
|
res = w._fix_short_preview_track('track', '1', str(tmp_path / 'gone.flac'), {})
|
|
assert res['success'] is True
|
|
conn = db._get_connection()
|
|
assert conn.execute("SELECT COUNT(*) FROM tracks WHERE id=1").fetchone()[0] == 0
|
|
conn.close()
|