soulsync/tests/test_download_origins.py
BoulderBadgeDad 1f7834cc7b Download Origins: see (and delete) exactly what watchlist + playlist syncs downloaded
User ask: "a modal that lists the tracks downloaded via watchlist" — extended,
as discussed, to playlists too. One modal, two tabs, opened from the Watchlist
page (watchlist tab preselected) and the Sync page (playlists tab) — same
shared-modal-different-entry-points UX as the rest of the app.

The data: library_history recorded which SERVICE a file came from but never
what TRIGGERED it. New origin/origin_context columns (migration + index) are
written once at the import chokepoint via core/downloads/origin.py, a pure
tested deriver that reads, in priority: an explicit _dl_origin stamp (set at
batch-task creation for direct playlist batches, where the playlist context
otherwise only survived in folder mode), the wishlist provenance already
riding in track_info.source_info (watchlist_artist_name / playlist_name —
watchlist_scanner has stamped these for ages), and the folder-mode playlist
thread. Manual downloads stay unclassified by design. History starts from
now — provenance can't be conjured retroactively.

API: GET /api/download-origins?origin=watchlist|playlist (paged) and POST
/api/download-origins/delete — deletes the file on disk (resolved through the
shared container/host path resolver), the matching library track row, and the
history entries; a file that refuses deletion keeps its row and reports the
error instead of lying.

UI: webui/static/origin-history.js — tabbed modal in the revamp design
language (accent light-edge, pill tabs, entry rows reusing the
library-history-entry components), per-row delete + select-all bulk delete
with honest result toasts, empty/loading states, per-tab totals.

Tests: 8 — deriver priority/shapes (incl. the exact watchlist_scanner
source_info shape and JSON-string survival), origin filtering + counts,
row fetch/delete isolation between origins, delete-track-by-path.
2026-06-07 00:15:31 -07:00

116 lines
5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Download-origin provenance: the deriver + the library_history persistence.
Feature: the origin-history modal (watchlist page / sync page) lists which
downloads were triggered by a watchlist scan vs a playlist sync, and lets the
user delete them. The trigger is derived once at the import chokepoint and
stored on the library_history row.
"""
from __future__ import annotations
import json
from core.downloads.origin import derive_download_origin
from database.music_database import MusicDatabase
# ── deriver ──────────────────────────────────────────────────────────────────
def test_explicit_stamp_wins():
ctx = {'track_info': {
'_dl_origin': 'playlist', '_dl_origin_context': 'Discover Weekly',
'source_info': {'watchlist_artist_name': 'Drake'}, # would say watchlist
}}
assert derive_download_origin(ctx) == ('playlist', 'Discover Weekly')
def test_watchlist_provenance_from_wishlist_source_info():
# The exact shape watchlist_scanner writes into the wishlist row, which
# rides into track_info when the wishlist worker downloads the item.
ctx = {'track_info': {'source_info': {
'watchlist_artist_name': 'Kendrick Lamar',
'watchlist_artist_id': 'spot123',
'album_name': 'GNX',
}}}
assert derive_download_origin(ctx) == ('watchlist', 'Kendrick Lamar')
def test_playlist_provenance_from_source_info_and_json_string():
ctx = {'track_info': {'source_info': {'playlist_name': 'Release Radar'}}}
assert derive_download_origin(ctx) == ('playlist', 'Release Radar')
# source_info sometimes survives as a JSON string — parse it.
ctx2 = {'track_info': {'source_info': json.dumps({'playlist_name': 'RapCaviar'})}}
assert derive_download_origin(ctx2) == ('playlist', 'RapCaviar')
def test_playlist_folder_mode_thread():
ctx = {'track_info': {'_playlist_name': 'Todays Top Hits'}}
assert derive_download_origin(ctx) == ('playlist', 'Todays Top Hits')
def test_manual_and_garbage_derive_none():
assert derive_download_origin({'track_info': {'name': 'Song'}}) == (None, '')
assert derive_download_origin({}) == (None, '')
assert derive_download_origin({'track_info': 'not-a-dict'}) == (None, '')
# invalid explicit origin is ignored, not trusted
assert derive_download_origin({'track_info': {'_dl_origin': 'aliens'}}) == (None, '')
# ── persistence ──────────────────────────────────────────────────────────────
def _seed(db):
db.add_library_history_entry(
event_type='download', title='Squabble Up', artist_name='Kendrick Lamar',
album_name='GNX', file_path='/music/k/squabble.flac',
origin='watchlist', origin_context='Kendrick Lamar')
db.add_library_history_entry(
event_type='download', title='Opalite', artist_name='Taylor Swift',
album_name='Showgirl', file_path='/music/t/opalite.flac',
origin='playlist', origin_context='Release Radar')
db.add_library_history_entry( # manual download — no origin
event_type='download', title='Random', artist_name='Someone',
file_path='/music/r/random.flac')
def test_origin_entries_filtered_and_counted(tmp_path):
db = MusicDatabase(str(tmp_path / 'm.db'))
_seed(db)
wl, wl_total = db.get_download_origin_entries('watchlist')
pl, pl_total = db.get_download_origin_entries('playlist')
assert wl_total == 1 and wl[0]['title'] == 'Squabble Up'
assert wl[0]['origin_context'] == 'Kendrick Lamar'
assert pl_total == 1 and pl[0]['title'] == 'Opalite'
assert pl[0]['origin_context'] == 'Release Radar'
def test_history_rows_fetch_and_delete(tmp_path):
db = MusicDatabase(str(tmp_path / 'm.db'))
_seed(db)
entries, _ = db.get_download_origin_entries('watchlist')
ids = [e['id'] for e in entries]
rows = db.get_library_history_rows_by_ids(ids)
assert rows and rows[0]['file_path'] == '/music/k/squabble.flac'
assert db.delete_library_history_rows(ids) == 1
assert db.get_download_origin_entries('watchlist')[1] == 0
# the other origin untouched
assert db.get_download_origin_entries('playlist')[1] == 1
def test_delete_track_by_file_path(tmp_path):
db = MusicDatabase(str(tmp_path / 'm.db'))
conn = db._get_connection()
cur = conn.cursor()
cur.execute("INSERT INTO artists (id, name) VALUES ('a1', 'A')")
cur.execute("INSERT INTO albums (id, title, artist_id) VALUES ('al1', 'Al', 'a1')")
cur.execute("""INSERT INTO tracks (id, album_id, artist_id, title, file_path)
VALUES ('t1', 'al1', 'a1', 'Song', '/music/k/squabble.flac')""")
conn.commit()
conn.close()
assert db.delete_track_by_file_path('/music/k/squabble.flac') == 1
assert db.delete_track_by_file_path('/music/k/squabble.flac') == 0
assert db.delete_track_by_file_path('') == 0