- add neutral wishlist payload helpers while keeping legacy Spotify aliases - route wishlist removal and classification through generic track data - keep API and service compatibility for existing callers
563 lines
17 KiB
Python
563 lines
17 KiB
Python
import json
|
|
|
|
import core.wishlist.routes as routes_module
|
|
from core.wishlist.routes import (
|
|
WishlistRouteRuntime,
|
|
add_album_track_to_wishlist,
|
|
clear_wishlist,
|
|
get_wishlist_count,
|
|
get_wishlist_cycle,
|
|
get_wishlist_stats,
|
|
get_wishlist_tracks,
|
|
process_wishlist_api,
|
|
remove_album_from_wishlist,
|
|
remove_batch_from_wishlist,
|
|
remove_track_from_wishlist,
|
|
set_wishlist_cycle,
|
|
)
|
|
|
|
|
|
class _FakeLogger:
|
|
def __init__(self):
|
|
self.info_messages = []
|
|
self.warning_messages = []
|
|
self.error_messages = []
|
|
self.debug_messages = []
|
|
|
|
def info(self, msg, *args):
|
|
self.info_messages.append(msg % args if args else msg)
|
|
|
|
def warning(self, msg, *args):
|
|
self.warning_messages.append(msg % args if args else msg)
|
|
|
|
def error(self, msg, *args):
|
|
self.error_messages.append(msg % args if args else msg)
|
|
|
|
def debug(self, msg, *args):
|
|
self.debug_messages.append(msg % args if args else msg)
|
|
|
|
|
|
class _FakeThread:
|
|
def __init__(self, target=None, daemon=False):
|
|
self.target = target
|
|
self.daemon = daemon
|
|
self.started = False
|
|
|
|
def start(self):
|
|
self.started = True
|
|
if self.target:
|
|
self.target()
|
|
|
|
|
|
class _FakeThreadFactory:
|
|
def __init__(self):
|
|
self.created = []
|
|
|
|
def __call__(self, *args, **kwargs):
|
|
thread = _FakeThread(*args, **kwargs)
|
|
self.created.append(thread)
|
|
return thread
|
|
|
|
|
|
class _FakeLock:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
|
|
class _FakeCursor:
|
|
def __init__(self, db):
|
|
self.db = db
|
|
self.last_sql = ""
|
|
|
|
def execute(self, sql, params=None):
|
|
self.last_sql = sql
|
|
if "INSERT OR REPLACE INTO metadata" in sql and params:
|
|
self.db.cycle_value = params[0]
|
|
|
|
def fetchone(self):
|
|
if "SELECT value FROM metadata WHERE key = 'wishlist_cycle'" in self.last_sql:
|
|
return {"value": self.db.cycle_value}
|
|
return None
|
|
|
|
|
|
class _FakeConnection:
|
|
def __init__(self, db):
|
|
self.db = db
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def cursor(self):
|
|
return self.db.cursor_obj
|
|
|
|
def commit(self):
|
|
self.db.commits += 1
|
|
|
|
|
|
class _FakeMusicDatabase:
|
|
def __init__(self, cycle_value="albums", duplicate_removals=0):
|
|
self.cycle_value = cycle_value
|
|
self.duplicate_removals = duplicate_removals
|
|
self.commits = 0
|
|
self.cursor_obj = _FakeCursor(self)
|
|
self.duplicate_cleanup_profiles = []
|
|
|
|
def _get_connection(self):
|
|
return _FakeConnection(self)
|
|
|
|
def remove_wishlist_duplicates(self, profile_id=1):
|
|
self.duplicate_cleanup_profiles.append(profile_id)
|
|
return self.duplicate_removals
|
|
|
|
|
|
class _FakeWishlistService:
|
|
def __init__(self, tracks=None, count=None, clear_result=True):
|
|
self.tracks = list(tracks or [])
|
|
self.count = len(self.tracks) if count is None else count
|
|
self.clear_result = clear_result
|
|
self.removed = []
|
|
self.add_calls = []
|
|
|
|
def get_wishlist_count(self, profile_id=1):
|
|
return self.count
|
|
|
|
def get_wishlist_tracks_for_download(self, profile_id=1):
|
|
return list(self.tracks)
|
|
|
|
def clear_wishlist(self, profile_id=1):
|
|
return self.clear_result
|
|
|
|
def remove_track_from_wishlist(self, spotify_track_id, profile_id=1):
|
|
self.removed.append((spotify_track_id, profile_id))
|
|
return True
|
|
|
|
def add_track_to_wishlist(self, **kwargs):
|
|
self.add_calls.append(kwargs)
|
|
return True
|
|
|
|
def add_spotify_track_to_wishlist(self, **kwargs):
|
|
self.add_calls.append(kwargs)
|
|
return True
|
|
|
|
|
|
def _build_runtime(
|
|
*,
|
|
tracks=None,
|
|
count=None,
|
|
cycle_value="albums",
|
|
duplicate_removals=0,
|
|
clear_result=True,
|
|
actually_processing=False,
|
|
next_run_seconds=0,
|
|
download_batches=None,
|
|
download_tasks=None,
|
|
thread_factory=None,
|
|
reset_callback=None,
|
|
):
|
|
service = _FakeWishlistService(tracks=tracks, count=count, clear_result=clear_result)
|
|
routes_module.get_wishlist_service = lambda: service
|
|
db = _FakeMusicDatabase(cycle_value=cycle_value, duplicate_removals=duplicate_removals)
|
|
logger = _FakeLogger()
|
|
activity_calls = []
|
|
runtime = WishlistRouteRuntime(
|
|
get_music_database=lambda: db,
|
|
profile_id=1,
|
|
download_batches=download_batches if download_batches is not None else {},
|
|
download_tasks=download_tasks if download_tasks is not None else {},
|
|
tasks_lock=_FakeLock(),
|
|
is_wishlist_actually_processing=lambda: actually_processing,
|
|
reset_wishlist_processing_state=reset_callback or (lambda: None),
|
|
add_activity_item=lambda *args: activity_calls.append(args),
|
|
logger=logger,
|
|
active_server="navidrome",
|
|
get_next_run_seconds=(lambda _name: next_run_seconds),
|
|
thread_factory=thread_factory or _FakeThreadFactory(),
|
|
)
|
|
return runtime, service, db, logger, activity_calls
|
|
|
|
|
|
def test_process_wishlist_api_starts_background_thread_when_idle():
|
|
thread_factory = _FakeThreadFactory()
|
|
runtime, _service, _db, logger, _activity_calls = _build_runtime(
|
|
thread_factory=thread_factory,
|
|
)
|
|
start_calls = []
|
|
|
|
payload, status = process_wishlist_api(runtime, start_processing=lambda: start_calls.append("ran"))
|
|
|
|
assert status == 200
|
|
assert payload == {"success": True, "message": "Wishlist processing started"}
|
|
assert start_calls == ["ran"]
|
|
assert len(thread_factory.created) == 1
|
|
assert thread_factory.created[0].daemon is True
|
|
assert thread_factory.created[0].started is True
|
|
assert logger.error_messages == []
|
|
|
|
|
|
def test_process_wishlist_api_rejects_when_flag_is_set():
|
|
thread_factory = _FakeThreadFactory()
|
|
runtime, _service, _db, logger, _activity_calls = _build_runtime(
|
|
actually_processing=True,
|
|
thread_factory=thread_factory,
|
|
)
|
|
|
|
payload, status = process_wishlist_api(runtime, start_processing=lambda: None)
|
|
|
|
assert status == 409
|
|
assert payload == {"success": False, "error": "Wishlist processing already in progress"}
|
|
assert thread_factory.created == []
|
|
assert logger.error_messages == []
|
|
|
|
|
|
def test_get_wishlist_count_returns_profile_count():
|
|
runtime, _service, _db, _logger, _activity_calls = _build_runtime(count=7)
|
|
|
|
payload, status = get_wishlist_count(runtime)
|
|
|
|
assert status == 200
|
|
assert payload == {"count": 7}
|
|
|
|
|
|
def test_get_wishlist_stats_uses_cycle_and_next_run():
|
|
tracks = [
|
|
{
|
|
"id": "track-1",
|
|
"name": "Single Song",
|
|
"artists": [{"name": "Artist One"}],
|
|
"spotify_data": {"album": {"album_type": "single"}},
|
|
},
|
|
{
|
|
"id": "track-2",
|
|
"name": "Album Song",
|
|
"artists": [{"name": "Artist Two"}],
|
|
"spotify_data": {"album": {"total_tracks": 8}},
|
|
},
|
|
]
|
|
runtime, _service, _db, _logger, _activity_calls = _build_runtime(
|
|
tracks=tracks,
|
|
count=2,
|
|
cycle_value="albums",
|
|
actually_processing=True,
|
|
next_run_seconds=123,
|
|
)
|
|
|
|
payload, status = get_wishlist_stats(runtime)
|
|
|
|
assert status == 200
|
|
assert payload == {
|
|
"singles": 1,
|
|
"albums": 1,
|
|
"total": 2,
|
|
"next_run_in_seconds": 123,
|
|
"is_auto_processing": True,
|
|
"current_cycle": "albums",
|
|
}
|
|
|
|
|
|
def test_get_wishlist_tracks_filters_category_and_cleans_duplicates():
|
|
tracks = [
|
|
{
|
|
"id": "track-1",
|
|
"name": "Single One",
|
|
"artists": [{"name": "Artist One"}],
|
|
"spotify_data": {"album": {"album_type": "single"}},
|
|
},
|
|
{
|
|
"id": "track-2",
|
|
"name": "Single Two",
|
|
"artists": [{"name": "Artist Two"}],
|
|
"spotify_data": {"album": {"album_type": "single"}},
|
|
},
|
|
{
|
|
"id": "track-3",
|
|
"name": "Album Song",
|
|
"artists": [{"name": "Artist Three"}],
|
|
"spotify_data": {"album": {"total_tracks": 8}},
|
|
},
|
|
]
|
|
runtime, service, db, logger, _activity_calls = _build_runtime(
|
|
tracks=tracks,
|
|
duplicate_removals=2,
|
|
)
|
|
|
|
payload, status = get_wishlist_tracks(runtime, category="singles", limit=1)
|
|
|
|
assert status == 200
|
|
assert payload["category"] == "singles"
|
|
assert payload["total"] == 2
|
|
assert len(payload["tracks"]) == 1
|
|
assert payload["tracks"][0]["id"] == "track-1"
|
|
assert db.duplicate_cleanup_profiles == [1]
|
|
assert any("duplicate tracks from wishlist" in msg for msg in logger.warning_messages)
|
|
assert service.get_wishlist_tracks_for_download(profile_id=1)[0]["id"] == "track-1"
|
|
|
|
|
|
def test_clear_wishlist_cancels_active_batches_and_resets_state():
|
|
download_batches = {
|
|
"batch-1": {
|
|
"playlist_id": "wishlist",
|
|
"phase": "analysis",
|
|
"queue": ["task-1", "task-2", "task-3"],
|
|
},
|
|
"batch-2": {
|
|
"playlist_id": "other",
|
|
"phase": "analysis",
|
|
"queue": ["task-4"],
|
|
},
|
|
}
|
|
download_tasks = {
|
|
"task-1": {"status": "queued"},
|
|
"task-2": {"status": "in_progress"},
|
|
"task-3": {"status": "completed"},
|
|
"task-4": {"status": "queued"},
|
|
}
|
|
reset_calls = []
|
|
runtime, service, _db, logger, activity_calls = _build_runtime(
|
|
download_batches=download_batches,
|
|
download_tasks=download_tasks,
|
|
reset_callback=lambda: reset_calls.append("reset"),
|
|
)
|
|
|
|
payload, status = clear_wishlist(runtime)
|
|
|
|
assert status == 200
|
|
assert payload == {
|
|
"success": True,
|
|
"message": "Wishlist cleared successfully",
|
|
"cancelled_downloads": 2,
|
|
}
|
|
assert service.clear_result is True
|
|
assert download_batches["batch-1"]["phase"] == "cancelled"
|
|
assert download_batches["batch-2"]["phase"] == "analysis"
|
|
assert download_tasks["task-1"]["status"] == "cancelled"
|
|
assert download_tasks["task-2"]["status"] == "cancelled"
|
|
assert download_tasks["task-3"]["status"] == "completed"
|
|
assert download_tasks["task-4"]["status"] == "queued"
|
|
assert reset_calls == ["reset"]
|
|
assert activity_calls == [
|
|
("", "Wishlist Cleared", "Wishlist cleared and 2 downloads cancelled", "Now")
|
|
]
|
|
assert any("Cancelled 2 active wishlist downloads" in msg for msg in logger.warning_messages)
|
|
|
|
|
|
def test_remove_track_from_wishlist_requires_track_id():
|
|
runtime, _service, _db, _logger, _activity_calls = _build_runtime()
|
|
|
|
payload, status = remove_track_from_wishlist(runtime, None)
|
|
|
|
assert status == 400
|
|
assert payload == {"success": False, "error": "No spotify_track_id provided"}
|
|
|
|
|
|
def test_remove_track_from_wishlist_removes_single_track():
|
|
runtime, service, _db, _logger, _activity_calls = _build_runtime()
|
|
|
|
payload, status = remove_track_from_wishlist(runtime, "track-1")
|
|
|
|
assert status == 200
|
|
assert payload == {"success": True, "message": "Track removed from wishlist"}
|
|
assert service.removed == [("track-1", 1)]
|
|
|
|
|
|
def test_remove_album_from_wishlist_matches_album_name():
|
|
tracks = [
|
|
{
|
|
"wishlist_id": 1,
|
|
"spotify_track_id": "track-1",
|
|
"id": "track-1",
|
|
"spotify_data": json.dumps(
|
|
{
|
|
"album": {"name": "Complete Album"},
|
|
"artists": [{"name": "Artist One"}],
|
|
}
|
|
),
|
|
},
|
|
{
|
|
"wishlist_id": 2,
|
|
"spotify_track_id": "track-2",
|
|
"id": "track-2",
|
|
"spotify_data": {
|
|
"album": {"name": "Other Album"},
|
|
"artists": [{"name": "Artist Two"}],
|
|
},
|
|
},
|
|
]
|
|
runtime, service, _db, _logger, _activity_calls = _build_runtime(tracks=tracks)
|
|
|
|
payload, status = remove_album_from_wishlist(runtime, album_name_filter="complete album")
|
|
|
|
assert status == 200
|
|
assert payload == {
|
|
"success": True,
|
|
"message": "Removed 1 track(s) from wishlist",
|
|
"removed_count": 1,
|
|
}
|
|
assert service.removed == [("track-1", 1)]
|
|
|
|
|
|
def test_remove_batch_from_wishlist_returns_removed_count():
|
|
runtime, service, _db, _logger, _activity_calls = _build_runtime()
|
|
|
|
payload, status = remove_batch_from_wishlist(runtime, ["track-1", "track-2"])
|
|
|
|
assert status == 200
|
|
assert payload == {
|
|
"success": True,
|
|
"removed": 2,
|
|
"message": "Removed 2 tracks from wishlist",
|
|
}
|
|
assert service.removed == [("track-1", 1), ("track-2", 1)]
|
|
|
|
|
|
def test_set_wishlist_cycle_updates_metadata():
|
|
runtime, _service, db, _logger, _activity_calls = _build_runtime(cycle_value="albums")
|
|
|
|
payload, status = set_wishlist_cycle(runtime, "singles")
|
|
|
|
assert status == 200
|
|
assert payload == {"success": True, "cycle": "singles"}
|
|
assert db.cycle_value == "singles"
|
|
assert db.commits == 1
|
|
|
|
|
|
def test_get_wishlist_cycle_returns_stored_value():
|
|
runtime, _service, _db, _logger, _activity_calls = _build_runtime(cycle_value="singles")
|
|
|
|
payload, status = get_wishlist_cycle(runtime)
|
|
|
|
assert status == 200
|
|
assert payload == {"cycle": "singles"}
|
|
|
|
|
|
def test_add_album_track_to_wishlist_builds_spotify_payload_and_merges_context():
|
|
runtime, service, _db, _logger, _activity_calls = _build_runtime()
|
|
track = {
|
|
"id": "track-1",
|
|
"name": "Song One",
|
|
"artists": [{"name": "Artist One"}],
|
|
"duration_ms": 1234,
|
|
"track_number": 2,
|
|
"disc_number": 1,
|
|
"explicit": True,
|
|
"popularity": 77,
|
|
"preview_url": "https://example.test/preview",
|
|
"external_urls": {"spotify": "https://open.spotify.com/track/track-1"},
|
|
}
|
|
artist = {"id": "artist-1", "name": "Artist One"}
|
|
album = {
|
|
"id": "album-1",
|
|
"name": "Album One",
|
|
"artists": [{"name": "Artist One"}],
|
|
"image_url": "https://example.test/cover.jpg",
|
|
"release_date": "2024-01-01",
|
|
"total_tracks": 10,
|
|
}
|
|
|
|
payload, status = add_album_track_to_wishlist(
|
|
runtime,
|
|
track=track,
|
|
artist=artist,
|
|
album=album,
|
|
source_type="album",
|
|
source_context={"playlist_id": "pl-1"},
|
|
)
|
|
|
|
assert status == 200
|
|
assert payload == {"success": True, "message": "Added 'Song One' to wishlist"}
|
|
assert len(service.add_calls) == 1
|
|
add_call = service.add_calls[0]
|
|
assert add_call["failure_reason"] == "Added from library (incomplete album)"
|
|
assert add_call["source_type"] == "album"
|
|
assert add_call["profile_id"] == 1
|
|
assert add_call["source_context"] == {
|
|
"playlist_id": "pl-1",
|
|
"artist_id": "artist-1",
|
|
"artist_name": "Artist One",
|
|
"album_id": "album-1",
|
|
"album_name": "Album One",
|
|
"added_via": "library_wishlist_modal",
|
|
}
|
|
assert add_call["track_data"]["album"]["images"] == [
|
|
{"url": "https://example.test/cover.jpg", "height": 640, "width": 640}
|
|
]
|
|
assert add_call["track_data"]["duration_ms"] == 1234
|
|
assert add_call["track_data"]["explicit"] is True
|
|
|
|
|
|
def test_set_wishlist_cycle_rejects_invalid_cycle():
|
|
runtime, _service, _db, _logger, _activity_calls = _build_runtime()
|
|
|
|
payload, status = set_wishlist_cycle(runtime, "mixes")
|
|
|
|
assert status == 400
|
|
assert payload == {"error": "Invalid cycle. Must be 'albums' or 'singles'"}
|
|
|
|
|
|
def test_remove_album_from_wishlist_matches_album_id():
|
|
tracks = [
|
|
{
|
|
"wishlist_id": 1,
|
|
"spotify_track_id": "track-1",
|
|
"id": "track-1",
|
|
"spotify_data": {
|
|
"album": {"id": "album-1", "name": "Complete Album"},
|
|
"artists": [{"name": "Artist One"}],
|
|
},
|
|
},
|
|
{
|
|
"wishlist_id": 2,
|
|
"spotify_track_id": "track-2",
|
|
"id": "track-2",
|
|
"spotify_data": {
|
|
"album": {"id": "album-2", "name": "Other Album"},
|
|
"artists": [{"name": "Artist Two"}],
|
|
},
|
|
},
|
|
]
|
|
runtime, service, _db, _logger, _activity_calls = _build_runtime(tracks=tracks)
|
|
|
|
payload, status = remove_album_from_wishlist(runtime, album_id="album-1")
|
|
|
|
assert status == 200
|
|
assert payload == {
|
|
"success": True,
|
|
"message": "Removed 1 track(s) from wishlist",
|
|
"removed_count": 1,
|
|
}
|
|
assert service.removed == [("track-1", 1)]
|
|
|
|
|
|
def test_remove_album_from_wishlist_requires_lookup_fields():
|
|
runtime, _service, _db, _logger, _activity_calls = _build_runtime()
|
|
|
|
payload, status = remove_album_from_wishlist(runtime)
|
|
|
|
assert status == 400
|
|
assert payload == {"success": False, "error": "No album_id or album_name provided"}
|
|
|
|
|
|
def test_remove_batch_from_wishlist_rejects_invalid_payload():
|
|
runtime, _service, _db, _logger, _activity_calls = _build_runtime()
|
|
|
|
payload, status = remove_batch_from_wishlist(runtime, "track-1")
|
|
|
|
assert status == 400
|
|
assert payload == {"success": False, "error": "Missing or invalid spotify_track_ids"}
|
|
|
|
|
|
def test_add_album_track_to_wishlist_requires_required_fields():
|
|
runtime, _service, _db, _logger, _activity_calls = _build_runtime()
|
|
|
|
payload, status = add_album_track_to_wishlist(runtime, track=None, artist=None, album=None)
|
|
|
|
assert status == 400
|
|
assert payload == {
|
|
"success": False,
|
|
"error": "Missing required fields: track, artist, album",
|
|
}
|