diff --git a/Makefile b/Makefile index ad62a7b..c78cfdb 100644 --- a/Makefile +++ b/Makefile @@ -31,9 +31,16 @@ NPM ?= pnpm backend-test-coverart-audiodb \ backend-test-dedup-cancellation \ backend-test-discovery \ + backend-test-discover-schemas \ + backend-test-daily-mix \ + backend-test-discover-picks \ + backend-test-discover-radio \ + backend-test-playlist-suggestions \ + backend-test-unexplored-genres \ backend-test-deep-discovery \ backend-test-discovery-precache \ backend-test-exception-handling \ + backend-test-genre-index \ backend-test-home \ backend-test-now-playing \ backend-test-home-genre \ @@ -52,6 +59,7 @@ NPM ?= pnpm backend-test-plex-repository \ backend-test-plex-routes \ backend-test-playlist \ + backend-test-queue-strategies \ backend-test-request-queue \ backend-test-request-service \ backend-test-search-top-result \ @@ -62,11 +70,14 @@ NPM ?= pnpm backend-test-sync-watchdog \ backend-test-content-enrichment \ backend-test-peer-review-fixes \ + backend-test-discover-all \ + test-discover-all \ test-audiodb-all test-mus14-all test-sync-all \ frontend-install frontend-build frontend-browser-install \ frontend-format-check frontend-check frontend-lint frontend-test frontend-test-server \ frontend-test-album-page \ frontend-test-audiodb-images \ + frontend-test-discover-page \ frontend-test-jellyfin \ frontend-test-monitored-artists \ frontend-test-navidrome \ @@ -147,6 +158,27 @@ backend-test-discovery-precache: $(BACKEND_VENV_STAMP) ## Run artist discovery p backend-test-exception-handling: $(BACKEND_VENV_STAMP) ## Run exception-handling regressions $(PYTEST) tests/routes/test_scrobble_routes.py tests/routes/test_scrobble_settings_routes.py tests/test_error_leakage.py tests/test_background_task_logging.py +backend-test-genre-index: $(BACKEND_VENV_STAMP) ## Run genre index tests + $(PYTEST) tests/infrastructure/test_genre_index.py -v + +backend-test-discover-schemas: $(BACKEND_VENV_STAMP) ## Run discover schema roundtrip tests + $(PYTEST) tests/schemas/test_discover_schemas.py -v + +backend-test-daily-mix: $(BACKEND_VENV_STAMP) ## Run daily mix section builder tests + $(PYTEST) tests/services/test_daily_mix.py -v + +backend-test-discover-picks: $(BACKEND_VENV_STAMP) ## Run discover picks section builder tests + $(PYTEST) tests/services/test_discover_picks.py -v + +backend-test-discover-radio: $(BACKEND_VENV_STAMP) ## Run discover radio tests + $(PYTEST) tests/services/test_discover_radio.py tests/routes/test_discover_radio_routes.py -v + +backend-test-playlist-suggestions: $(BACKEND_VENV_STAMP) ## Run playlist suggestion tests + $(PYTEST) tests/services/test_playlist_suggestions.py tests/routes/test_playlist_suggestions_routes.py -v + +backend-test-unexplored-genres: $(BACKEND_VENV_STAMP) ## Run unexplored genres tests + $(PYTEST) tests/services/test_unexplored_genres.py -v + backend-test-now-playing: $(BACKEND_VENV_STAMP) ## Run now-playing service and route tests $(PYTEST) tests/services/test_now_playing.py tests/routes/test_now_playing_routes.py -v @@ -195,6 +227,9 @@ backend-test-performance: $(BACKEND_VENV_STAMP) ## Run performance regression te backend-test-playlist: $(BACKEND_VENV_STAMP) ## Run playlist tests $(PYTEST) tests/services/test_playlist_service.py tests/services/test_playlist_source_resolution.py tests/repositories/test_playlist_repository.py tests/routes/test_playlist_routes.py +backend-test-queue-strategies: $(BACKEND_VENV_STAMP) ## Run queue strategy extraction tests + $(PYTEST) tests/services/test_queue_strategies.py -v + backend-test-request-queue: $(BACKEND_VENV_STAMP) ## Run MUS-14 request queue tests (dedup, cancel, concurrency) $(PYTEST) tests/infrastructure/test_request_queue_mus14.py tests/infrastructure/test_queue_persistence.py -v @@ -237,6 +272,10 @@ backend-test-sync-resume: $(BACKEND_VENV_STAMP) ## Run sync resume-on-failure te backend-test-sync-watchdog: $(BACKEND_VENV_STAMP) ## Run adaptive watchdog timeout tests $(PYTEST) tests/test_sync_watchdog.py -v +backend-test-discover-all: backend-test-queue-strategies backend-test-daily-mix backend-test-discover-picks backend-test-discover-radio backend-test-unexplored-genres backend-test-playlist-suggestions backend-test-genre-index ## Run all discover expansion tests + +test-discover-all: backend-test-discover-all frontend-test-discover-page ## Run all discover expansion tests (backend + frontend) + test-audiodb-all: backend-test-audiodb backend-test-audiodb-prewarm backend-test-audiodb-settings backend-test-coverart-audiodb backend-test-audiodb-phase8 backend-test-audiodb-phase9 frontend-test-audiodb-images ## Run every AudioDB test target test-mus14-all: backend-test-request-queue backend-test-artist-lock backend-test-request-service ## Run all MUS-14 request system tests @@ -293,6 +332,9 @@ frontend-test-navidrome: ## Run Navidrome frontend tests frontend-test-jellyfin: ## Run Jellyfin frontend tests cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server src/lib/player/jellyfinPlaybackApi.spec.ts +frontend-test-discover-page: ## Run discover page and query tests + cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server src/lib/queries/discover/DiscoverQuery.spec.ts + rebuild: ## Rebuild the application cd "$(ROOT_DIR)" && ./manage.sh --rebuild diff --git a/backend/api/v1/routes/discover.py b/backend/api/v1/routes/discover.py index 8e97dd0..7cb1b6c 100644 --- a/backend/api/v1/routes/discover.py +++ b/backend/api/v1/routes/discover.py @@ -11,6 +11,9 @@ from api.v1.schemas.discover import ( DiscoverQueueStatusResponse, QueueGenerateRequest, QueueGenerateResponse, + RadioRequest, + PlaylistSuggestionsRequest, + PlaylistSuggestionsResponse, YouTubeSearchResponse, YouTubeQuotaResponse, TrackCacheCheckRequest, @@ -18,6 +21,7 @@ from api.v1.schemas.discover import ( TrackCacheCheckResponseItem, ) from api.v1.schemas.common import StatusMessageResponse +from api.v1.schemas.home import HomeSection from core.dependencies import get_discover_service, get_discover_queue_manager, get_youtube_repo from infrastructure.degradation import try_get_degradation_context from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute @@ -50,6 +54,22 @@ async def refresh_discover_data( return StatusMessageResponse(status="ok", message="Discover refresh triggered") +@router.post("/radio", response_model=HomeSection) +async def discover_radio( + body: RadioRequest = MsgSpecBody(RadioRequest), + service: DiscoverService = Depends(get_discover_service), +) -> HomeSection: + return await service.generate_radio(body) + + +@router.post("/playlist-suggestions", response_model=PlaylistSuggestionsResponse) +async def playlist_suggestions( + body: PlaylistSuggestionsRequest = MsgSpecBody(PlaylistSuggestionsRequest), + service: DiscoverService = Depends(get_discover_service), +) -> PlaylistSuggestionsResponse: + return await service.get_playlist_suggestions(body) + + @router.get("/queue", response_model=DiscoverQueueResponse) async def get_discover_queue( count: int | None = Query(default=None, description="Number of items (default from settings, max 20)"), diff --git a/backend/api/v1/schemas/advanced_settings.py b/backend/api/v1/schemas/advanced_settings.py index a02da92..ef2ff2c 100644 --- a/backend/api/v1/schemas/advanced_settings.py +++ b/backend/api/v1/schemas/advanced_settings.py @@ -87,6 +87,8 @@ class AdvancedSettings(AppStruct): discover_queue_albums_per_similar: int = 5 discover_queue_enrich_ttl: int = 86400 discover_queue_lastfm_mbid_max_lookups: int = 10 + discover_picks_genre_affinity_weight: float = 0.7 + discover_picks_count: int = 12 frontend_ttl_home: int = 300000 frontend_ttl_discover: int = 1800000 frontend_ttl_library: int = 300000 @@ -177,6 +179,8 @@ class AdvancedSettings(AppStruct): "discover_queue_albums_per_similar": (1, 20), "discover_queue_enrich_ttl": (3600, 604800), "discover_queue_lastfm_mbid_max_lookups": (1, 50), + "discover_picks_genre_affinity_weight": (0.0, 1.0), + "discover_picks_count": (4, 30), "frontend_ttl_home": (60000, 3600000), "frontend_ttl_discover": (60000, 86400000), "frontend_ttl_library": (60000, 3600000), @@ -270,6 +274,8 @@ class AdvancedSettingsFrontend(AppStruct): discover_queue_albums_per_similar: int = 5 discover_queue_enrich_ttl: int = 24 discover_queue_lastfm_mbid_max_lookups: int = 10 + discover_picks_genre_affinity_weight: float = 0.7 + discover_picks_count: int = 12 frontend_ttl_home: int = 5 frontend_ttl_discover: int = 30 frontend_ttl_library: int = 5 @@ -335,6 +341,7 @@ class AdvancedSettingsFrontend(AppStruct): "ignored_releases_retention_days", "orphan_cover_demote_interval_hours", "store_prune_interval_hours", + "discover_picks_count", ] for field_name in int_coerce_fields: setattr(self, field_name, _coerce_positive_int(getattr(self, field_name), field_name)) @@ -391,6 +398,8 @@ class AdvancedSettingsFrontend(AppStruct): "discover_queue_albums_per_similar": (1, 20), "discover_queue_enrich_ttl": (1, 168), "discover_queue_lastfm_mbid_max_lookups": (1, 50), + "discover_picks_genre_affinity_weight": (0.0, 1.0), + "discover_picks_count": (4, 30), "frontend_ttl_home": (1, 60), "frontend_ttl_discover": (1, 1440), "frontend_ttl_library": (1, 60), @@ -476,6 +485,8 @@ class AdvancedSettingsFrontend(AppStruct): discover_queue_albums_per_similar=settings.discover_queue_albums_per_similar, discover_queue_enrich_ttl=settings.discover_queue_enrich_ttl // 3600, discover_queue_lastfm_mbid_max_lookups=settings.discover_queue_lastfm_mbid_max_lookups, + discover_picks_genre_affinity_weight=settings.discover_picks_genre_affinity_weight, + discover_picks_count=settings.discover_picks_count, frontend_ttl_home=settings.frontend_ttl_home // 60000, frontend_ttl_discover=settings.frontend_ttl_discover // 60000, frontend_ttl_library=settings.frontend_ttl_library // 60000, @@ -562,6 +573,8 @@ class AdvancedSettingsFrontend(AppStruct): discover_queue_albums_per_similar=self.discover_queue_albums_per_similar, discover_queue_enrich_ttl=self.discover_queue_enrich_ttl * 3600, discover_queue_lastfm_mbid_max_lookups=self.discover_queue_lastfm_mbid_max_lookups, + discover_picks_genre_affinity_weight=self.discover_picks_genre_affinity_weight, + discover_picks_count=self.discover_picks_count, frontend_ttl_home=self.frontend_ttl_home * 60000, frontend_ttl_discover=self.frontend_ttl_discover * 60000, frontend_ttl_library=self.frontend_ttl_library * 60000, diff --git a/backend/api/v1/schemas/discover.py b/backend/api/v1/schemas/discover.py index 6340e21..82f96e9 100644 --- a/backend/api/v1/schemas/discover.py +++ b/backend/api/v1/schemas/discover.py @@ -1,3 +1,5 @@ +from typing import Literal + from api.v1.schemas.home import HomeArtist, HomeSection, ServicePrompt from api.v1.schemas.common import GenreArtistMap, IntegrationStatus from api.v1.schemas.weekly_exploration import WeeklyExplorationSection @@ -134,6 +136,31 @@ class DiscoverIgnoredRelease(AppStruct): ignored_at: float +class RadioRequest(AppStruct): + seed_type: Literal["artist", "album", "genre"] + seed_id: str + count: int = 10 + source: Literal["listenbrainz", "lastfm"] | None = None + + +class PlaylistProfile(AppStruct): + artist_mbids: list[str] = [] + genre_distribution: dict[str, list[str]] = {} + track_count: int = 0 + + +class PlaylistSuggestionsRequest(AppStruct): + playlist_id: str + count: int = 10 + source: Literal["listenbrainz", "lastfm"] | None = None + + +class PlaylistSuggestionsResponse(AppStruct): + suggestions: HomeSection + playlist_id: str + profile: PlaylistProfile + + class DiscoverIntegrationStatus(IntegrationStatus): pass @@ -156,5 +183,9 @@ class DiscoverResponse(AppStruct): lastfm_weekly_artist_chart: HomeSection | None = None lastfm_weekly_album_chart: HomeSection | None = None lastfm_recent_scrobbles: HomeSection | None = None + daily_mixes: list[HomeSection] = [] + radio_sections: list[HomeSection] = [] + discover_picks: HomeSection | None = None + unexplored_genres: HomeSection | None = None refreshing: bool = False service_status: dict[str, str] | None = None diff --git a/backend/api/v1/schemas/home.py b/backend/api/v1/schemas/home.py index eafea4f..0c415b6 100644 --- a/backend/api/v1/schemas/home.py +++ b/backend/api/v1/schemas/home.py @@ -51,6 +51,8 @@ class HomeSection(AppStruct): source: str | None = None fallback_message: str | None = None connect_service: str | None = None + radio_seed_type: str | None = None + radio_seed_id: str | None = None class ServicePrompt(AppStruct): diff --git a/backend/core/dependencies/service_providers.py b/backend/core/dependencies/service_providers.py index a71fa9c..285f8f7 100644 --- a/backend/core/dependencies/service_providers.py +++ b/backend/core/dependencies/service_providers.py @@ -307,6 +307,7 @@ def get_playlist_service() -> "PlaylistService": repo=playlist_repo, cache_dir=settings.cache_dir, cache=get_cache(), + genre_index=get_genre_index(), ) @@ -517,6 +518,10 @@ def get_scrobble_service() -> "ScrobbleService": @singleton def get_discover_service() -> "DiscoverService": from services.discover_service import DiscoverService + from services.discover.radio_service import DiscoverRadioService + from services.discover.mbid_resolution_service import MbidResolutionService + from services.discover.integration_helpers import IntegrationHelpers + from services.home_transformers import HomeDataTransformers listenbrainz_repo = get_listenbrainz_repository() jellyfin_repo = get_jellyfin_repository() @@ -529,6 +534,27 @@ def get_discover_service() -> "DiscoverService": wikidata_repo = get_wikidata_repository() lastfm_repo = get_lastfm_repository() audiodb_image_service = get_audiodb_image_service() + genre_index = get_genre_index() + + radio_mbid_svc = MbidResolutionService( + musicbrainz_repo=musicbrainz_repo, + lidarr_repo=lidarr_repo, + listenbrainz_repo=listenbrainz_repo, + library_db=library_db, + mbid_store=mbid_store, + ) + radio_integration = IntegrationHelpers(preferences_service) + radio_service = DiscoverRadioService( + lb_repo=listenbrainz_repo, + mb_repo=musicbrainz_repo, + mbid_svc=radio_mbid_svc, + artist_discovery=get_artist_discovery_service(), + album_discovery=get_album_discovery_service(), + genre_index=genre_index, + integration=radio_integration, + transformers=HomeDataTransformers(jellyfin_repo), + ) + return DiscoverService( listenbrainz_repo=listenbrainz_repo, jellyfin_repo=jellyfin_repo, @@ -541,6 +567,9 @@ def get_discover_service() -> "DiscoverService": wikidata_repo=wikidata_repo, lastfm_repo=lastfm_repo, audiodb_image_service=audiodb_image_service, + genre_index=genre_index, + radio_service=radio_service, + playlist_service=get_playlist_service(), ) diff --git a/backend/infrastructure/persistence/genre_index.py b/backend/infrastructure/persistence/genre_index.py index fe5310f..235aaa8 100644 --- a/backend/infrastructure/persistence/genre_index.py +++ b/backend/infrastructure/persistence/genre_index.py @@ -1,5 +1,6 @@ """Domain 2 - Genre indexing persistence.""" +import logging import sqlite3 from typing import Any @@ -11,6 +12,8 @@ from infrastructure.persistence._database import ( _normalize, ) +logger = logging.getLogger(__name__) + LIBRARY_ARTISTS_TABLE = "library_artists" LIBRARY_ALBUMS_TABLE = "library_albums" @@ -172,3 +175,122 @@ class GenreIndex(PersistenceBase): return _decode_rows(rows) return await self._read(operation) + + async def get_top_genres(self, limit: int = 20) -> list[tuple[str, int]]: + """Return (genre_lower, artist_count) pairs ordered by count DESC. + + Consumed by ``_build_genre_list()`` in ``homepage_service.py``. + """ + + def operation(conn: sqlite3.Connection) -> list[tuple[str, int]]: + rows = conn.execute( + """ + SELECT g.genre_lower, + COUNT(DISTINCT g.artist_mbid_lower) AS cnt + FROM artist_genre_lookup g + JOIN library_artists la ON la.mbid_lower = g.artist_mbid_lower + GROUP BY g.genre_lower + ORDER BY cnt DESC, g.genre_lower ASC + LIMIT ? + """, + (max(limit, 1),), + ).fetchall() + return [(row["genre_lower"], int(row["cnt"])) for row in rows] + + return await self._read(operation) + + async def get_genre_artist_counts(self, genres: list[str]) -> dict[str, int]: + """Return {genre_lower: library_artist_count} for the given genres.""" + normalized = [_normalize_genre(g) for g in genres if g] + if not normalized: + return {} + + def operation(conn: sqlite3.Connection) -> dict[str, int]: + placeholders = ",".join("?" * len(normalized)) + rows = conn.execute( + f""" + SELECT g.genre_lower, + COUNT(DISTINCT g.artist_mbid_lower) AS cnt + FROM artist_genre_lookup g + JOIN library_artists la ON la.mbid_lower = g.artist_mbid_lower + WHERE g.genre_lower IN ({placeholders}) + GROUP BY g.genre_lower + """, + normalized, + ).fetchall() + return {row["genre_lower"]: int(row["cnt"]) for row in rows} + + return await self._read(operation) + + async def get_artists_for_genres(self, genres: list[str]) -> dict[str, list[str]]: + """Return {genre_lower: [artist_mbid_lower, ...]} for library artists.""" + normalized = [_normalize_genre(g) for g in genres if g] + if not normalized: + return {} + + def operation(conn: sqlite3.Connection) -> dict[str, list[str]]: + placeholders = ",".join("?" * len(normalized)) + rows = conn.execute( + f""" + SELECT g.genre_lower, g.artist_mbid_lower + FROM artist_genre_lookup g + JOIN library_artists la ON la.mbid_lower = g.artist_mbid_lower + WHERE g.genre_lower IN ({placeholders}) + """, + normalized, + ).fetchall() + result: dict[str, list[str]] = {} + for row in rows: + result.setdefault(row["genre_lower"], []).append(row["artist_mbid_lower"]) + return result + + return await self._read(operation) + + async def get_genres_for_artists(self, artist_mbids: list[str]) -> dict[str, list[str]]: + """Return {artist_mbid_lower: [genre_display_name, ...]} from artist_genres.""" + normalized = [_normalize(m) for m in artist_mbids if m] + if not normalized: + return {} + + def operation(conn: sqlite3.Connection) -> dict[str, list[str]]: + placeholders = ",".join("?" * len(normalized)) + rows = conn.execute( + f"SELECT artist_mbid_lower, genres_json FROM artist_genres WHERE artist_mbid_lower IN ({placeholders})", + normalized, + ).fetchall() + result: dict[str, list[str]] = {} + for row in rows: + try: + genres = _decode_json(row["genres_json"]) + except (ValueError, TypeError) as exc: + logger.warning( + "genre_index.get_genres_for_artists: failed to decode genres_json for artist %s: %s", + row["artist_mbid_lower"], + exc, + ) + continue + if isinstance(genres, list): + result[row["artist_mbid_lower"]] = _clean_genres(genres) + return result + + return await self._read(operation) + + async def get_underrepresented_genres(self, known_genres: list[str], threshold: int = 2) -> list[str]: + """Return genre_lower values with < threshold library artists, excluding known_genres.""" + known_lower = {_normalize_genre(g) for g in known_genres} + + def operation(conn: sqlite3.Connection) -> list[str]: + rows = conn.execute( + """ + SELECT g.genre_lower, COUNT(DISTINCT g.artist_mbid_lower) AS cnt + FROM artist_genre_lookup g + JOIN library_artists la ON la.mbid_lower = g.artist_mbid_lower + GROUP BY g.genre_lower + HAVING cnt < ? AND cnt >= 1 + ORDER BY cnt DESC + """, + (max(threshold, 1),), + ).fetchall() + return [row["genre_lower"] for row in rows if row["genre_lower"] not in known_lower] + + return await self._read(operation) diff --git a/backend/services/discover/facade.py b/backend/services/discover/facade.py index 44ec73f..e0826b9 100644 --- a/backend/services/discover/facade.py +++ b/backend/services/discover/facade.py @@ -9,10 +9,14 @@ from __future__ import annotations import asyncio from typing import Any +from fastapi import HTTPException + from api.v1.schemas.discover import ( DiscoverQueueEnrichment, DiscoverQueueResponse, DiscoverIgnoredRelease, + PlaylistSuggestionsRequest, + PlaylistSuggestionsResponse, ) from api.v1.schemas.home import DiscoverPreview from infrastructure.cache.memory_cache import CacheInterface @@ -24,11 +28,13 @@ from repositories.protocols import ( MusicBrainzRepositoryProtocol, LastFmRepositoryProtocol, ) +from api.v1.schemas.home import HomeSection from services.discover.enrichment_service import QueueEnrichmentService from services.discover.homepage_service import DiscoverHomepageService from services.discover.integration_helpers import IntegrationHelpers from services.discover.mbid_resolution_service import MbidResolutionService from services.discover.queue_service import DiscoverQueueService +from services.discover.radio_service import DiscoverRadioService from services.preferences_service import PreferencesService @@ -52,6 +58,9 @@ class DiscoverService: wikidata_repo: Any = None, lastfm_repo: LastFmRepositoryProtocol | None = None, audiodb_image_service: Any = None, + genre_index: Any = None, + radio_service: Any = None, + playlist_service: Any = None, ): self._integration = IntegrationHelpers(preferences_service) @@ -94,8 +103,12 @@ class DiscoverService: memory_cache=memory_cache, lastfm_repo=lastfm_repo, audiodb_image_service=audiodb_image_service, + genre_index=genre_index, + mbid_store=mbid_store, ) + self._radio = radio_service + self._playlist_service = playlist_service async def get_discover_data(self, source: str | None = None): return await self._homepage.get_discover_data(source) @@ -112,6 +125,29 @@ class DiscoverService: async def build_discover_data(self, source: str | None = None): return await self._homepage.build_discover_data(source) + async def generate_radio(self, request: Any) -> HomeSection: + if self._radio is None: + raise HTTPException(status_code=501, detail="Radio service not configured") + return await self._radio.generate_radio(request) + + async def get_playlist_suggestions( + self, request: PlaylistSuggestionsRequest, + ) -> PlaylistSuggestionsResponse: + profile = await self._playlist_service.analyse_playlist_profile( + request.playlist_id, + ) + if profile is None: + raise HTTPException(status_code=404, detail="Playlist not found") + if not profile.artist_mbids: + raise HTTPException(status_code=422, detail="This playlist has no artist data to base suggestions on") + section = await self._homepage.build_playlist_suggestions( + profile, request.count, request.source, + ) + return PlaylistSuggestionsResponse( + suggestions=section, + playlist_id=request.playlist_id, + profile=profile, + ) async def build_queue(self, count: int | None = None, source: str | None = None) -> DiscoverQueueResponse: return await self._queue.build_queue(count, source) @@ -127,7 +163,6 @@ class DiscoverService: async def get_ignored_releases(self) -> list[DiscoverIgnoredRelease]: return await self._queue.get_ignored_releases() - async def enrich_queue_item(self, release_group_mbid: str) -> DiscoverQueueEnrichment: return await self._enrichment.enrich_queue_item(release_group_mbid) diff --git a/backend/services/discover/homepage_service.py b/backend/services/discover/homepage_service.py index 82637b7..0360b99 100644 --- a/backend/services/discover/homepage_service.py +++ b/backend/services/discover/homepage_service.py @@ -1,5 +1,6 @@ import asyncio import logging +import random from datetime import datetime, timezone from typing import Any @@ -7,6 +8,8 @@ from api.v1.schemas.discover import ( DiscoverResponse, BecauseYouListenTo, DiscoverIntegrationStatus, + DiscoverQueueItemLight, + PlaylistProfile, ) from api.v1.schemas.home import ( HomeSection, @@ -18,6 +21,7 @@ from api.v1.schemas.home import ( ) from infrastructure.cache.memory_cache import CacheInterface from infrastructure.cover_urls import prefer_artist_cover_url +from infrastructure.persistence import MBIDStore from infrastructure.serialization import clone_with_updates from repositories.protocols import ( ListenBrainzRepositoryProtocol, @@ -30,6 +34,7 @@ from repositories.listenbrainz_models import ListenBrainzArtist from services.home_transformers import HomeDataTransformers from services.discover.integration_helpers import IntegrationHelpers from services.discover.mbid_resolution_service import MbidResolutionService +from services.discover.queue_strategies import build_similar_artist_pools, build_similar_artist_pools_lastfm, discover_by_genres, queue_item_to_home_album, round_robin_dedup_select from services.weekly_exploration_service import WeeklyExplorationService logger = logging.getLogger(__name__) @@ -40,6 +45,10 @@ REDISCOVER_MONTHS_AGO = 3 MISSING_ESSENTIALS_MIN_ALBUMS = 3 MISSING_ESSENTIALS_MAX_PER_ARTIST = 3 VARIOUS_ARTISTS_MBID = "89ad4ac3-39f7-470e-963a-56509c546377" +DAILY_MIX_CACHE_TTL = 86400 # 24 hours +DISCOVER_PICKS_CACHE_TTL = 14400 # 4 hours +UNEXPLORED_GENRES_THRESHOLD = 2 +UNEXPLORED_GENRES_MAX = 8 class DiscoverHomepageService: @@ -54,6 +63,8 @@ class DiscoverHomepageService: memory_cache: CacheInterface | None = None, lastfm_repo: LastFmRepositoryProtocol | None = None, audiodb_image_service: Any = None, + genre_index: Any = None, + mbid_store: MBIDStore | None = None, ) -> None: self._lb_repo = listenbrainz_repo self._jf_repo = jellyfin_repo @@ -64,10 +75,19 @@ class DiscoverHomepageService: self._memory_cache = memory_cache self._lfm_repo = lastfm_repo self._audiodb_image_service = audiodb_image_service + self._genre_index = genre_index + self._mbid_store = mbid_store self._transformers = HomeDataTransformers(jellyfin_repo) self._weekly_exploration = WeeklyExplorationService(listenbrainz_repo, musicbrainz_repo) self._building = False + def _daily_mix_cache_key(self, source: str) -> str: + today = datetime.now(timezone.utc).date().isoformat() + return f"daily_mix:{source}:{today}" + + def _discover_picks_cache_key(self, source: str) -> str: + return f"discover_picks:{source}" + async def get_discover_data(self, source: str | None = None) -> DiscoverResponse: resolved_source = self._integration.resolve_source(source) if self._memory_cache: @@ -158,6 +178,10 @@ class DiscoverHomepageService: or response.lastfm_weekly_album_chart or response.lastfm_recent_scrobbles or response.weekly_exploration + or response.daily_mixes + or response.discover_picks + or response.radio_sections + or response.unexplored_genres ) async def build_discover_data(self, source: str | None = None) -> DiscoverResponse: @@ -254,12 +278,22 @@ class DiscoverHomepageService: "lastfm_recent_scrobbles": self._build_lastfm_recent_scrobbles( results, library_mbids, monitored_mbids ), + "daily_mixes": self._build_daily_mix_sections(resolved_source, library_mbids), + "discover_picks": self._build_discover_picks( + library_mbids, resolved_source, lb_enabled, username, + ), + "radio_sections": self._build_radio_sections( + seed_artists, library_mbids, resolved_source, + ), } if resolved_source == "listenbrainz" and lb_enabled and username: post_tasks["weekly_exploration"] = self._weekly_exploration.build_section(username) post_results = await self._execute_tasks(post_tasks) response.missing_essentials = post_results.get("missing_essentials") response.weekly_exploration = post_results.get("weekly_exploration") + response.daily_mixes = post_results.get("daily_mixes") or [] + response.discover_picks = post_results.get("discover_picks") + response.radio_sections = post_results.get("radio_sections") or [] response.rediscover = self._build_rediscover(results, library_mbids, jf_enabled) @@ -275,6 +309,18 @@ class DiscoverHomepageService: response.genre_list = self._build_genre_list(results, lb_enabled) + similar_artist_mbids: list[str] = [] + for i in range(3): + similar = results.get(f"similar_{i}") or [] + for artist in similar: + mbid = getattr(artist, 'artist_mbid', None) or getattr(artist, 'mbid', None) + if mbid: + similar_artist_mbids.append(mbid) + + response.unexplored_genres = await self._build_unexplored_genres( + response.because_you_listen_to, similar_artist_mbids + ) + if response.genre_list and response.genre_list.items: genre_names = [ g.name for g in response.genre_list.items[:20] @@ -322,6 +368,101 @@ class DiscoverHomepageService: return response + async def build_playlist_suggestions( + self, + profile: PlaylistProfile, + count: int = 10, + source: str | None = None, + ) -> HomeSection: + resolved_source = self._integration.resolve_source(source) + + lb_enabled = self._integration.is_listenbrainz_enabled() + lfm_enabled = self._integration.is_lastfm_enabled() + source_available = ( + (resolved_source == "listenbrainz" and lb_enabled) + or (resolved_source == "lastfm" and lfm_enabled) + ) + if not source_available: + return HomeSection( + title="Suggestions for your playlist", + type="albums", + items=[], + source=resolved_source, + fallback_message="The music source you selected isn't set up yet.", + ) + + sample_size = min(3, len(profile.artist_mbids)) + seed_mbids = random.sample(profile.artist_mbids, sample_size) + + if resolved_source == "lastfm" and self._lfm_repo is not None: + pools = await build_similar_artist_pools_lastfm( + seed_mbids, + excluded_mbids=set(profile.artist_mbids), + similar_limit=15, + albums_per=3, + lfm_repo=self._lfm_repo, + mbid_svc=self._mbid, + ) + else: + seeds = [ + ListenBrainzArtist( + artist_name=mbid, + artist_mbids=[mbid], + listen_count=0, + ) + for mbid in seed_mbids + ] + pools = await build_similar_artist_pools( + seeds, + excluded_mbids=set(profile.artist_mbids), + similar_limit=15, + albums_per=3, + lb_repo=self._lb_repo, + mbid_svc=self._mbid, + ) + + if profile.genre_distribution: + all_genres: list[str] = [] + seen_genres: set[str] = set() + for genre_list in profile.genre_distribution.values(): + for g in genre_list: + gl = g.lower() + if gl not in seen_genres: + seen_genres.add(gl) + all_genres.append(g) + if len(all_genres) >= 4: + break + if len(all_genres) >= 4: + break + if all_genres: + genre_items = await discover_by_genres( + all_genres, + excluded_mbids=set(profile.artist_mbids), + mb_repo=self._mb_repo, + mbid_svc=self._mbid, + ) + if genre_items: + pools.append(genre_items) + + selected = round_robin_dedup_select(pools, count) + albums = [queue_item_to_home_album(item) for item in selected] + + if not albums: + return HomeSection( + title="Suggestions for your playlist", + type="albums", + items=[], + source=resolved_source, + fallback_message="Not enough suggestions for this playlist yet. Try adding more tracks.", + ) + + return HomeSection( + title="Suggestions for your playlist", + type="albums", + items=albums, + source=resolved_source, + ) + async def _get_seed_artists( self, lb_enabled: bool, @@ -477,6 +618,340 @@ class DiscoverHomepageService: return sections + async def _build_daily_mix_sections(self, resolved_source: str, library_mbids: set[str]) -> list[HomeSection]: + """Build 3-5 genre-clustered daily mix sections with 60/40 new-to-familiar ratio.""" + try: + if self._genre_index is None: + return [] + + if self._memory_cache: + cache_key = self._daily_mix_cache_key(resolved_source) + cached = await self._memory_cache.get(cache_key) + if cached is not None: + return cached # type: ignore[return-value] + + top_genres = await self._genre_index.get_top_genres(limit=20) + if not top_genres: + await self._cache_daily_mix_result([], resolved_source) + return [] + + genre_names = [g for g, _ in top_genres[:10]] + artists_by_genre = await self._genre_index.get_artists_for_genres(genre_names) + + MIN_ARTISTS_PER_CLUSTER = 3 + MAX_CLUSTERS = 5 + candidate_clusters: list[tuple[str, list[str]]] = [] + seen_artists: set[str] = set() + for genre_lower, _count in top_genres: + artist_mbids = artists_by_genre.get(genre_lower, []) + unique = [a for a in artist_mbids if a not in seen_artists] + if len(unique) < MIN_ARTISTS_PER_CLUSTER: + continue + candidate_clusters.append((genre_lower, unique)) + seen_artists.update(unique) + + candidate_clusters.sort(key=lambda c: len(c[1]), reverse=True) + clusters = candidate_clusters[:MAX_CLUSTERS] + + if not clusters: + await self._cache_daily_mix_result([], resolved_source) + return [] + + sections: list[HomeSection] = [] + for i, (genre_lower, cluster_artists) in enumerate(clusters): + try: + section = await self._build_single_daily_mix( + i, genre_lower, cluster_artists, resolved_source, library_mbids, + ) + if section: + sections.append(section) + except Exception as e: # noqa: BLE001 + logger.warning(f"Daily mix cluster {i} ({genre_lower}) failed: {e}") + continue + + await self._cache_daily_mix_result(sections, resolved_source) + return sections + + except Exception as e: # noqa: BLE001 + logger.warning(f"Daily mix builder failed: {e}") + return [] + + async def _build_single_daily_mix( + self, + index: int, + genre_lower: str, + cluster_artists: list[str], + resolved_source: str, + library_mbids: set[str], + ) -> HomeSection | None: + """Build a single daily mix section for a genre cluster.""" + genre_label = genre_lower.title() + MAX_ITEMS = 12 + + seed_count = min(3, len(cluster_artists)) + seed_mbids = random.sample(cluster_artists, seed_count) + + name_results = await asyncio.gather( + *[ + self._lb_repo.get_artist_top_release_groups(mbid, count=1) + for mbid in seed_mbids + ], + return_exceptions=True, + ) + seed_names: dict[str, str] = {} + for mbid, result in zip(seed_mbids, name_results): + if isinstance(result, Exception) or not result: + continue + resolved_name = getattr(result[0], "artist_name", None) + if resolved_name: + seed_names[mbid] = resolved_name + + seeds = [ + ListenBrainzArtist( + artist_mbids=[mbid], + artist_name=seed_names.get(mbid, f"{genre_label} artist"), + listen_count=0, + ) + for mbid in seed_mbids + ] + + new_items: list[HomeAlbum] = [] + try: + pools = await build_similar_artist_pools( + seeds=seeds, + excluded_mbids=library_mbids, + similar_limit=10, + albums_per=3, + lb_repo=self._lb_repo, + mbid_svc=self._mbid, + ) + for pool in pools: + for item in pool: + new_items.append(HomeAlbum( + name=item.album_name, + mbid=item.release_group_mbid, + artist_name=item.artist_name, + artist_mbid=item.artist_mbid, + image_url=f"/api/v1/covers/release-group/{item.release_group_mbid}?size=500", + )) + except Exception as e: # noqa: BLE001 + logger.debug(f"Daily mix {index}: similar artist pools failed: {e}") + + familiar_items: list[HomeAlbum] = [] + try: + library_albums = await self._genre_index.get_albums_by_genre(genre_lower, limit=20) + for album in library_albums: + if isinstance(album, dict): + mbid = album.get("release_group_mbid", album.get("mbid", "")) + familiar_items.append(HomeAlbum( + name=album.get("title", album.get("name", "Unknown")), + mbid=mbid, + artist_name=album.get("artist_name", album.get("artist", "")), + artist_mbid=album.get("artist_mbid"), + image_url=( + f"/api/v1/covers/release-group/{mbid}?size=500" if mbid else None + ), + in_library=True, + )) + except Exception as e: # noqa: BLE001 + logger.debug(f"Daily mix {index}: library albums fetch failed: {e}") + + seen_mbids: set[str] = set() + deduped_new: list[HomeAlbum] = [] + for item in new_items: + key = item.mbid.lower() if item.mbid else "" + if key and key not in seen_mbids: + seen_mbids.add(key) + deduped_new.append(item) + new_items = deduped_new + + deduped_familiar: list[HomeAlbum] = [] + for item in familiar_items: + key = item.mbid.lower() if item.mbid else "" + if key and key not in seen_mbids: + seen_mbids.add(key) + deduped_familiar.append(item) + familiar_items = deduped_familiar + + new_count = min(len(new_items), round(MAX_ITEMS * 0.6)) + familiar_count = min(len(familiar_items), MAX_ITEMS - new_count) + if new_count + familiar_count < MAX_ITEMS: + extra_new = min(len(new_items) - new_count, MAX_ITEMS - new_count - familiar_count) + if extra_new > 0: + new_count += extra_new + extra_familiar = min( + len(familiar_items) - familiar_count, + MAX_ITEMS - new_count - familiar_count, + ) + if extra_familiar > 0: + familiar_count += extra_familiar + + merged: list[HomeAlbum] = new_items[:new_count] + familiar_items[:familiar_count] + if not merged: + return None + + return HomeSection( + title=f"Daily Mix {index + 1} - {genre_label}", + type="albums", + items=merged, + source=resolved_source, + ) + + async def _cache_daily_mix_result( + self, sections: list[HomeSection], source: str, + ) -> None: + """Cache daily mix result (including empty lists) with 24h TTL.""" + if self._memory_cache: + cache_key = self._daily_mix_cache_key(source) + await self._memory_cache.set(cache_key, sections, DAILY_MIX_CACHE_TTL) + + async def _build_discover_picks( + self, + library_mbids: set[str], + resolved_source: str, + lb_enabled: bool, + username: str | None, + ) -> HomeSection | None: + """Build a serendipity section of random undiscovered albums with genre-affinity weighting.""" + try: + if self._genre_index is None: + return None + + if self._memory_cache is not None: + cache_key = self._discover_picks_cache_key(resolved_source) + cached = await self._memory_cache.get(cache_key) + if isinstance(cached, dict) and "section" in cached: + return cached["section"] # type: ignore[return-value] + + affinity_weight, count = self._integration.get_discover_picks_settings() + + candidates: list = [] + if resolved_source == "lastfm" and self._lfm_repo is not None: + try: + top_artists = await asyncio.wait_for( + self._lfm_repo.get_global_top_artists(limit=30), + timeout=30, + ) + valid_artists = [a for a in top_artists if a.mbid] + rg_results = await asyncio.gather( + *[ + asyncio.wait_for( + self._lb_repo.get_artist_top_release_groups( + artist.mbid, count=3, + ), + timeout=30, + ) + for artist in valid_artists + ], + return_exceptions=True, + ) + for result in rg_results: + if isinstance(result, Exception): + continue + candidates.extend(result) + except asyncio.TimeoutError: + logger.warning("Timeout fetching top artists for discover picks") + except Exception: # noqa: BLE001 + pass + elif lb_enabled: + try: + candidates = await asyncio.wait_for( + self._lb_repo.get_sitewide_top_release_groups(count=100), + timeout=30, + ) + except asyncio.TimeoutError: + logger.warning("Timeout fetching sitewide top release groups for discover picks") + except Exception: # noqa: BLE001 + pass + + if not candidates: + await self._cache_discover_picks_result(None, resolved_source) + return None + + ignored_mbids: set[str] = set() + if self._mbid_store is not None: + try: + ignored_mbids = await self._mbid_store.get_ignored_release_mbids() + except Exception: # noqa: BLE001 + logger.warning("Failed to load ignored release MBIDs for discover picks") + + exclude_mbids = library_mbids | ignored_mbids + filtered = [ + c for c in candidates + if c.release_group_mbid + and c.release_group_mbid.lower() not in exclude_mbids + ] + + if not filtered: + await self._cache_discover_picks_result(None, resolved_source) + return None + + top_genres = await self._genre_index.get_top_genres(limit=10) + user_genres: set[str] = {g for g, _ in top_genres} if top_genres else set() + + artist_mbids_to_lookup: list[str] = [] + for c in filtered: + if c.artist_mbids: + artist_mbids_to_lookup.append(c.artist_mbids[0]) + + genres_by_artist: dict[str, list[str]] = {} + if artist_mbids_to_lookup: + genres_by_artist = await self._genre_index.get_genres_for_artists( + artist_mbids_to_lookup, + ) + + scored: list[tuple[float, object]] = [] + for c in filtered: + artist_mbid = c.artist_mbids[0] if c.artist_mbids else None + candidate_genres = ( + set(genres_by_artist.get(artist_mbid.lower(), [])) + if artist_mbid + else set() + ) + genre_overlap = ( + len(candidate_genres & user_genres) / max(len(user_genres), 1) + ) + score = affinity_weight * genre_overlap + (1 - affinity_weight) * random.random() + scored.append((score, c)) + + scored.sort(key=lambda x: x[0], reverse=True) + selected = [c for _, c in scored[:count]] + + items: list[HomeAlbum] = [] + for release in selected: + try: + items.append( + self._transformers.lb_release_to_home(release, library_mbids, None), + ) + except Exception: # noqa: BLE001 + continue + + if not items: + await self._cache_discover_picks_result(None, resolved_source) + return None + + section = HomeSection( + title="Discover Picks", + type="albums", + items=items, + source=resolved_source, + ) + await self._cache_discover_picks_result(section, resolved_source) + return section + + except Exception as e: # noqa: BLE001 + logger.warning(f"Discover picks builder failed: {e}") + return None + + async def _cache_discover_picks_result( + self, result: HomeSection | None, source: str, + ) -> None: + if self._memory_cache: + cache_key = self._discover_picks_cache_key(source) + await self._memory_cache.set( + cache_key, {"section": result}, DISCOVER_PICKS_CACHE_TTL, + ) + def _build_fresh_releases( self, results: dict[str, Any], library_mbids: set[str], monitored_mbids: set[str] | None = None, @@ -851,6 +1326,83 @@ class DiscoverHomepageService: source = "listenbrainz" if lb_genres else ("lidarr" if library_albums else None) return HomeSection(title="Browse by Genre", type="genres", items=genres, source=source) + async def _build_unexplored_genres( + self, + because_sections: list[BecauseYouListenTo], + similar_artist_mbids: list[str], + ) -> HomeSection | None: + if self._genre_index is None: + return None + try: + candidate_mbids: set[str] = set() + for section in because_sections: + for item in section.section.items: + if isinstance(item, HomeArtist) and item.mbid: + candidate_mbids.add(item.mbid) + for mbid in similar_artist_mbids: + candidate_mbids.add(mbid) + + genres_by_artist = await self._genre_index.get_genres_for_artists(list(candidate_mbids)) + candidate_genres: dict[str, str] = {} + for _artist, genre_list in genres_by_artist.items(): + for display_name in genre_list: + lower = display_name.lower() + if lower not in candidate_genres: + candidate_genres[lower] = display_name + + if candidate_genres: + counts = await self._genre_index.get_genre_artist_counts(list(candidate_genres.values())) + else: + counts = {} + + top_genres_raw = await self._genre_index.get_top_genres(limit=20) + top_genre_lowers = {g.lower() for g, _ in top_genres_raw} + + filtered: list[tuple[str, str, int]] = [] + for lower, display in candidate_genres.items(): + count = counts.get(lower, 0) + if count >= UNEXPLORED_GENRES_THRESHOLD: + continue + if lower in top_genre_lowers: + continue + filtered.append((lower, display, count)) + + random.shuffle(filtered) + filtered = filtered[:UNEXPLORED_GENRES_MAX] + + if not filtered: + top_genre_names = [g for g, _ in top_genres_raw] + fallback = await self._genre_index.get_underrepresented_genres( + top_genre_names, threshold=UNEXPLORED_GENRES_THRESHOLD + ) + random.shuffle(fallback) + fallback = fallback[:UNEXPLORED_GENRES_MAX] + if not fallback: + return None + fallback_counts = await self._genre_index.get_genre_artist_counts(fallback) + genre_items: list[HomeGenre] = [ + HomeGenre(name=g.title(), artist_count=fallback_counts.get(g, 0)) + for g in fallback + ] + else: + genre_items = [ + HomeGenre(name=display, artist_count=count) + for _lower, display, count in filtered + ] + + if not genre_items: + return None + + return HomeSection( + title="Genres to Explore", + type="genres", + items=genre_items, + source=None, + ) + except Exception as e: # noqa: BLE001 + logger.warning(f"Unexplored genres builder failed: {e}") + return None + def _build_globally_trending( self, results: dict[str, Any], @@ -1049,7 +1601,7 @@ class DiscoverHomepageService: prompts.append(ServicePrompt( service="listenbrainz", title="Connect ListenBrainz", - description="Get recommendations from your listening history, find similar artists, and keep an eye on your top genres. Connect Last.fm too if you want global listener stats.", + description="Pulls recommendations from your listening history, finds similar artists, and tracks your top genres. Add Last.fm for global listener stats.", icon="LB", color="primary", features=["Personalized recommendations", "Similar artists", "Listening stats", "Genre insights"], @@ -1058,7 +1610,7 @@ class DiscoverHomepageService: prompts.append(ServicePrompt( service="jellyfin", title="Connect Jellyfin", - description="Use your play history to surface favorites and sharpen recommendations.", + description="Uses your play history to bring back old favorites and improve recommendations.", icon="JF", color="secondary", features=["Rediscover favorites", "Play statistics", "Listening history", "Better recommendations"], @@ -1067,7 +1619,7 @@ class DiscoverHomepageService: prompts.append(ServicePrompt( service="lidarr-connection", title="Connect Lidarr", - description="Spot gaps in your collection and keep your library in sync.", + description="Finds gaps in your collection and keeps your library up to date.", icon="LD", color="accent", features=["Missing essentials", "Library management", "Album requests", "Collection tracking"], @@ -1076,7 +1628,7 @@ class DiscoverHomepageService: prompts.append(ServicePrompt( service="lastfm", title="Connect Last.fm", - description="Track your listening, compare stats, and discover music that matches your taste.", + description="Tracks what you listen to, shows your stats, and suggests music based on your taste.", icon="FM", color="primary", features=["Scrobbling", "Global listener stats", "Artist recommendations", "Play history"], @@ -1097,3 +1649,50 @@ class DiscoverHomepageService: else: results[key] = result return results + + async def _build_radio_sections( + self, + seed_artists: list[ListenBrainzArtist], + library_mbids: set[str], + source: str, + ) -> list[HomeSection]: + valid_seeds = [ + seed for seed in seed_artists[:3] + if seed.artist_mbids + ] + if not valid_seeds: + return [] + + async def _build_one(seed: ListenBrainzArtist) -> HomeSection | None: + seed_mbid = seed.artist_mbids[0] + try: + pools = await asyncio.wait_for( + build_similar_artist_pools( + [seed], + excluded_mbids=library_mbids, + similar_limit=15, + albums_per=3, + lb_repo=self._lb_repo, + mbid_svc=self._mbid, + ), + timeout=30, + ) + selected = round_robin_dedup_select(pools, count=10) + albums = [queue_item_to_home_album(item) for item in selected] + return HomeSection( + title=f"Radio: {seed.artist_name}", + type="albums", + items=albums, + source=source, + radio_seed_type="artist", + radio_seed_id=seed_mbid, + ) + except asyncio.TimeoutError: + logger.warning("Radio section for seed %s timed out", seed_mbid[:8]) + return None + except Exception as e: # noqa: BLE001 + logger.warning("Radio section for seed %s failed: %s", seed_mbid[:8], e) + return None + + results = await asyncio.gather(*[_build_one(seed) for seed in valid_seeds]) + return [s for s in results if s is not None] diff --git a/backend/services/discover/integration_helpers.py b/backend/services/discover/integration_helpers.py index 2f1585b..c8354d9 100644 --- a/backend/services/discover/integration_helpers.py +++ b/backend/services/discover/integration_helpers.py @@ -84,3 +84,7 @@ class IntegrationHelpers: youtube=self.is_youtube_api_enabled(), lastfm=self.is_lastfm_enabled(), ) + + def get_discover_picks_settings(self) -> tuple[float, int]: + adv = self._preferences.get_advanced_settings() + return adv.discover_picks_genre_affinity_weight, adv.discover_picks_count diff --git a/backend/services/discover/queue_service.py b/backend/services/discover/queue_service.py index 114e191..96555ae 100644 --- a/backend/services/discover/queue_service.py +++ b/backend/services/discover/queue_service.py @@ -20,6 +20,14 @@ from repositories.protocols import ( from repositories.listenbrainz_models import ListenBrainzArtist from services.discover.integration_helpers import IntegrationHelpers from services.discover.mbid_resolution_service import MbidResolutionService +from services.discover.queue_strategies import ( + build_similar_artist_pools, + discover_by_genres, + get_artist_deep_cuts, + get_trending_filler, + interleave_at_positions, + round_robin_dedup_select, +) logger = logging.getLogger(__name__) @@ -208,54 +216,10 @@ class DiscoverQueueService: excluded_mbids: set[str], qs: QueueSettings, ) -> list[list[DiscoverQueueItemLight]]: - pools: list[list[DiscoverQueueItemLight]] = [[] for _ in range(len(seeds))] - - async def _process_seed(i: int, seed: ListenBrainzArtist) -> None: - seed_mbid = seed.artist_mbids[0] if seed.artist_mbids else None - if not seed_mbid: - return - - pool_seen: set[str] = set() - try: - similar = await self._lb_repo.get_similar_artists( - seed_mbid, - max_similar=qs.similar_artists_limit, - ) - for sim_artist in similar: - sim_mbid = self._mbid.normalize_mbid(sim_artist.artist_mbid) - if not sim_mbid or sim_mbid == VARIOUS_ARTISTS_MBID: - continue - - try: - release_groups = await self._lb_repo.get_artist_top_release_groups( - sim_mbid, - count=qs.albums_per_similar, - ) - except Exception as e: # noqa: BLE001 - logger.debug(f"Failed to get releases for similar artist: {e}") - continue - - for rg in release_groups: - rg_mbid = self._mbid.normalize_mbid(rg.release_group_mbid) - if not rg_mbid: - continue - if rg_mbid in excluded_mbids or rg_mbid in pool_seen: - continue - pools[i].append( - self._mbid.make_queue_item( - release_group_mbid=rg_mbid, - album_name=rg.release_group_name, - artist_name=rg.artist_name, - artist_mbid=sim_mbid, - reason=f"Similar to {seed.artist_name}", - ) - ) - pool_seen.add(rg_mbid) - except Exception as e: # noqa: BLE001 - logger.debug(f"Failed to get similar artists for seed {seed_mbid[:8]}: {e}") - - await asyncio.gather(*[_process_seed(i, seed) for i, seed in enumerate(seeds)]) - return pools + return await build_similar_artist_pools( + seeds, excluded_mbids, qs.similar_artists_limit, + qs.albums_per_similar, lb_repo=self._lb_repo, mbid_svc=self._mbid, + ) async def _strategy_lb_genre_discovery( self, @@ -263,49 +227,20 @@ class DiscoverQueueService: excluded_mbids: set[str], ) -> list[DiscoverQueueItemLight]: try: - genres = await self._lb_repo.get_user_genre_activity(username) + genre_activity = await self._lb_repo.get_user_genre_activity(username) except Exception: # noqa: BLE001 logger.warning("Failed to fetch user genre activity from ListenBrainz") return [] - if not genres: + if not genre_activity: return [] - top_genres = [genre.genre for genre in genres[:4] if getattr(genre, "genre", None)] - if not top_genres: - return [] - - search_results = await asyncio.gather( - *[ - self._mb_repo.search_release_groups_by_tag(tag=genre, limit=8) - for genre in top_genres - ], - return_exceptions=True, + genres = [g.genre for g in genre_activity if getattr(g, "genre", None)] + return await discover_by_genres( + genres, excluded_mbids, + mb_repo=self._mb_repo, mbid_svc=self._mbid, ) - items: list[DiscoverQueueItemLight] = [] - seen: set[str] = set() - for genre, result in zip(top_genres, search_results): - if isinstance(result, Exception): - continue - for release in result: - rg_mbid = self._mbid.normalize_mbid(getattr(release, "musicbrainz_id", None)) - if not rg_mbid: - continue - if rg_mbid in excluded_mbids or rg_mbid in seen: - continue - items.append( - self._mbid.make_queue_item( - release_group_mbid=rg_mbid, - album_name=getattr(release, "title", "Unknown"), - artist_name=getattr(release, "artist", "Unknown") or "Unknown", - artist_mbid="", - reason=f"Because you listen to {genre}", - ) - ) - seen.add(rg_mbid) - return items - async def _strategy_lb_fresh_releases( self, username: str, @@ -420,79 +355,11 @@ class DiscoverQueueService: listened_mbids: set[str], albums_per_artist: int, ) -> list[DiscoverQueueItemLight]: - try: - top_release_groups = await self._lb_repo.get_user_top_release_groups( - username=username, - range_="this_month", - count=25, - ) - except Exception: # noqa: BLE001 - logger.warning("Failed to fetch top release groups from ListenBrainz for deep cuts") - return [] - - if not top_release_groups: - return [] - - current_top_mbids = { - rg.release_group_mbid.lower() - for rg in top_release_groups - if getattr(rg, "release_group_mbid", None) - } - - artist_seed_names: dict[str, str] = {} - for rg in top_release_groups: - rg_artist_mbids = getattr(rg, "artist_mbids", None) or [] - if not rg_artist_mbids: - continue - artist_mbid = self._mbid.normalize_mbid(rg_artist_mbids[0]) - if not artist_mbid or artist_mbid in artist_seed_names: - continue - artist_seed_names[artist_mbid] = getattr(rg, "artist_name", "") - if len(artist_seed_names) >= 6: - break - - if not artist_seed_names: - return [] - - artist_mbid_list = list(artist_seed_names.keys()) - results = await asyncio.gather( - *[ - self._lb_repo.get_artist_top_release_groups( - a_mbid, - count=max(albums_per_artist + 2, 4), - ) - for a_mbid in artist_mbid_list - ], - return_exceptions=True, + return await get_artist_deep_cuts( + username, excluded_mbids, listened_mbids, + albums_per_artist, lb_repo=self._lb_repo, mbid_svc=self._mbid, ) - items: list[DiscoverQueueItemLight] = [] - seen_rg_mbids: set[str] = set() - for a_mbid, result in zip(artist_mbid_list, results): - if isinstance(result, Exception): - continue - for rg in result: - rg_mbid = self._mbid.normalize_mbid(rg.release_group_mbid) - if not rg_mbid: - continue - if rg_mbid in current_top_mbids or rg_mbid in listened_mbids: - continue - if rg_mbid in excluded_mbids or rg_mbid in seen_rg_mbids: - continue - - source_artist_name = artist_seed_names.get(a_mbid) or rg.artist_name - items.append( - self._mbid.make_queue_item( - release_group_mbid=rg_mbid, - album_name=rg.release_group_name, - artist_name=rg.artist_name, - artist_mbid=a_mbid, - reason=f"More from {source_artist_name}", - ) - ) - seen_rg_mbids.add(rg_mbid) - return items - async def _build_personalized_queue( self, count: int, @@ -639,160 +506,26 @@ class DiscoverQueueService: def _round_robin_select( self, pools: list[list[DiscoverQueueItemLight]], count: int ) -> list[DiscoverQueueItemLight]: - selected: list[DiscoverQueueItemLight] = [] - seen_mbids: set[str] = set() - artist_counts: dict[str, int] = {} - max_per_artist = 2 - - for pool in pools: - random.shuffle(pool) - - pool_indices = [0] * len(pools) - - for _ in range(count * 3): - if len(selected) >= count: - break - for pool_idx in range(len(pools)): - if len(selected) >= count: - break - pool = pools[pool_idx] - idx = pool_indices[pool_idx] - while idx < len(pool): - item = pool[idx] - idx += 1 - pool_indices[pool_idx] = idx - mbid_lower = item.release_group_mbid.lower() - artist_key = item.artist_mbid.lower() if item.artist_mbid else "" - if mbid_lower in seen_mbids: - continue - if artist_key and artist_counts.get(artist_key, 0) >= max_per_artist: - continue - selected.append(item) - seen_mbids.add(mbid_lower) - if artist_key: - artist_counts[artist_key] = artist_counts.get(artist_key, 0) + 1 - break - - return selected + return round_robin_dedup_select(pools, count) async def _get_wildcard_albums( self, count: int, ignored_mbids: set[str], library_album_mbids: set[str], seen_mbids: set[str] | None = None, resolved_source: str = "listenbrainz", ) -> list[DiscoverQueueItemLight]: - if count <= 0: - return [] - exclude = ignored_mbids | library_album_mbids | (seen_mbids or set()) - use_lastfm = resolved_source == "lastfm" and self._integration.is_lastfm_enabled() and self._lfm_repo is not None - target = max(count * 2, 6) - - try: - if use_lastfm: - top_artists = await self._lfm_repo.get_global_top_artists(limit=15) - random.shuffle(top_artists) - valid_artists = [ - a - for a in top_artists[:10] - if self._mbid.normalize_mbid(a.mbid) != VARIOUS_ARTISTS_MBID - ] - album_fetch_results = await asyncio.gather( - *[ - self._lfm_repo.get_artist_top_albums( - a.name, mbid=a.mbid, limit=3 - ) - for a in valid_artists - ], - return_exceptions=True, - ) - artist_albums_pairs: list[tuple[Any, list]] = [] - for artist, result in zip(valid_artists, album_fetch_results): - if isinstance(result, Exception): - continue - artist_albums_pairs.append((artist, result)) - wildcards = await self._mbid.lastfm_albums_to_queue_items( - artist_albums_pairs, - exclude=exclude, - target=target, - reason="Trending on Last.fm", - is_wildcard=True, - ) - else: - rgs = await self._lb_repo.get_sitewide_top_release_groups(count=25) - random.shuffle(rgs) - wildcards: list[DiscoverQueueItemLight] = [] - for rg in rgs: - if len(wildcards) >= target: - break - rg_mbid = rg.release_group_mbid - if not rg_mbid or rg_mbid.lower() in exclude: - continue - artist_mbid = rg.artist_mbids[0] if rg.artist_mbids else "" - if artist_mbid.lower() == VARIOUS_ARTISTS_MBID: - continue - wildcards.append(DiscoverQueueItemLight( - release_group_mbid=rg_mbid, - album_name=rg.release_group_name, - artist_name=rg.artist_name, - artist_mbid=artist_mbid, - cover_url=f"/api/v1/covers/release-group/{rg_mbid}?size=500", - recommendation_reason="Trending This Week", - is_wildcard=True, - in_library=False, - )) - except Exception as e: # noqa: BLE001 - logger.debug(f"Failed to get wildcard albums: {e}") - wildcards = [] - - if not wildcards: - if use_lastfm: - decade_tags = ["2020s", "2010s", "2000s", "1990s", "1980s", "1970s"] - for decade in decade_tags: - if len(wildcards) >= target: - break - try: - decade_releases = await self._mb_repo.search_release_groups_by_tag( - tag=decade, - limit=25, - offset=0, - ) - except Exception: # noqa: BLE001 - logger.warning("Failed to search release groups for decade tag %s", decade) - continue - for release in decade_releases: - if len(wildcards) >= target: - break - rg_mbid = self._mbid.normalize_mbid(getattr(release, "musicbrainz_id", None)) - if not rg_mbid or rg_mbid.lower() in exclude: - continue - wildcards.append(DiscoverQueueItemLight( - release_group_mbid=rg_mbid, - album_name=getattr(release, "title", "Unknown"), - artist_name=getattr(release, "artist", "Unknown") or "Unknown", - artist_mbid="", - cover_url=f"/api/v1/covers/release-group/{rg_mbid}?size=500", - recommendation_reason="Trending on Last.fm", - is_wildcard=True, - in_library=False, - )) - exclude.add(rg_mbid.lower()) - - if not wildcards: - logger.warning("Failed to populate any wildcard albums for discover queue") - - return wildcards[:count] + return await get_trending_filler( + count, ignored_mbids, library_album_mbids, seen_mbids, + resolved_source, lb_repo=self._lb_repo, mb_repo=self._mb_repo, + mbid_svc=self._mbid, lfm_repo=self._lfm_repo, + is_lastfm_enabled=self._integration.is_lastfm_enabled(), + ) def _interleave_wildcards( self, personalized: list[DiscoverQueueItemLight], wildcards: list[DiscoverQueueItemLight], ) -> list[DiscoverQueueItemLight]: - result = list(personalized) - positions = [2, 7] - for i, wc in enumerate(wildcards): - pos = positions[i] if i < len(positions) else len(result) - pos = min(pos, len(result)) - result.insert(pos, wc) - return result + return interleave_at_positions(personalized, wildcards) async def _build_anonymous_queue( self, count: int, ignored_mbids: set[str], library_album_mbids: set[str], diff --git a/backend/services/discover/queue_strategies.py b/backend/services/discover/queue_strategies.py new file mode 100644 index 0000000..05dc304 --- /dev/null +++ b/backend/services/discover/queue_strategies.py @@ -0,0 +1,480 @@ +"""Reusable queue-building strategy functions. + +Extracted from DiscoverQueueService for reuse by Popular Radio, +Playlist-Seeded Discovery, and Daily Mix builders. +""" + +import asyncio +import logging +import random + +from api.v1.schemas.discover import DiscoverQueueItemLight +from api.v1.schemas.home import HomeAlbum +from repositories.listenbrainz_models import ListenBrainzArtist +from repositories.protocols import ( + LastFmRepositoryProtocol, + ListenBrainzRepositoryProtocol, + MusicBrainzRepositoryProtocol, +) +from services.discover.mbid_resolution_service import MbidResolutionService + +logger = logging.getLogger(__name__) + +VARIOUS_ARTISTS_MBID = "89ad4ac3-39f7-470e-963a-56509c546377" + + +def queue_item_to_home_album(item: DiscoverQueueItemLight) -> HomeAlbum: + """Convert a DiscoverQueueItemLight to a HomeAlbum.""" + return HomeAlbum( + name=item.album_name, + mbid=item.release_group_mbid, + artist_name=item.artist_name, + artist_mbid=item.artist_mbid, + image_url=f"/api/v1/covers/release-group/{item.release_group_mbid}?size=500", + ) + + +async def build_similar_artist_pools( + seeds: list[ListenBrainzArtist], + excluded_mbids: set[str], + similar_limit: int, + albums_per: int, + *, + lb_repo: ListenBrainzRepositoryProtocol, + mbid_svc: MbidResolutionService, +) -> list[list[DiscoverQueueItemLight]]: + """Build one pool of candidate queue items per seed artist via LB similar-artist lookups.""" + pools: list[list[DiscoverQueueItemLight]] = [[] for _ in range(len(seeds))] + + async def _process_seed(i: int, seed: ListenBrainzArtist) -> None: + seed_mbid = seed.artist_mbids[0] if seed.artist_mbids else None + if not seed_mbid: + return + + pool_seen: set[str] = set() + try: + similar = await asyncio.wait_for( + lb_repo.get_similar_artists( + seed_mbid, + max_similar=similar_limit, + ), + timeout=30, + ) + for sim_artist in similar: + sim_mbid = mbid_svc.normalize_mbid(sim_artist.artist_mbid) + if not sim_mbid or sim_mbid == VARIOUS_ARTISTS_MBID: + continue + + try: + release_groups = await asyncio.wait_for( + lb_repo.get_artist_top_release_groups( + sim_mbid, + count=albums_per, + ), + timeout=30, + ) + except asyncio.TimeoutError: + logger.warning("Timeout getting releases for similar artist %s", sim_mbid[:8]) + continue + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get releases for similar artist: {e}") + continue + + for rg in release_groups: + rg_mbid = mbid_svc.normalize_mbid(rg.release_group_mbid) + if not rg_mbid: + continue + if rg_mbid in excluded_mbids or rg_mbid in pool_seen: + continue + pools[i].append( + mbid_svc.make_queue_item( + release_group_mbid=rg_mbid, + album_name=rg.release_group_name, + artist_name=rg.artist_name, + artist_mbid=sim_mbid, + reason=f"Similar to {seed.artist_name}", + ) + ) + pool_seen.add(rg_mbid) + except asyncio.TimeoutError: + logger.warning("Timeout getting similar artists for seed %s", seed_mbid[:8]) + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get similar artists for seed {seed_mbid[:8]}: {e}") + + await asyncio.gather(*[_process_seed(i, seed) for i, seed in enumerate(seeds)]) + return pools + + +async def build_similar_artist_pools_lastfm( + seed_mbids: list[str], + excluded_mbids: set[str], + similar_limit: int, + albums_per: int, + *, + lfm_repo: LastFmRepositoryProtocol, + mbid_svc: MbidResolutionService, +) -> list[list[DiscoverQueueItemLight]]: + """Build candidate pools per seed artist via Last.fm similar-artist lookups.""" + pools: list[list[DiscoverQueueItemLight]] = [[] for _ in range(len(seed_mbids))] + + async def _process_seed(i: int, seed_mbid: str) -> None: + pool_seen: set[str] = set() + try: + similar = await asyncio.wait_for( + lfm_repo.get_similar_artists( + "", mbid=seed_mbid, limit=similar_limit, + ), + timeout=30, + ) + for sim_artist in similar: + sim_mbid = mbid_svc.normalize_mbid(sim_artist.mbid) + if not sim_mbid or sim_mbid == VARIOUS_ARTISTS_MBID: + continue + + try: + top_albums = await asyncio.wait_for( + lfm_repo.get_artist_top_albums( + sim_artist.name, mbid=sim_artist.mbid, limit=albums_per, + ), + timeout=30, + ) + except asyncio.TimeoutError: + logger.warning("Timeout getting Last.fm top albums for %s", sim_artist.name) + continue + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get top albums for Last.fm similar artist: {e}") + continue + + artist_albums_pair = [(sim_artist, top_albums)] + items = await mbid_svc.lastfm_albums_to_queue_items( + artist_albums_pair, + exclude=excluded_mbids | pool_seen, + target=albums_per, + reason=f"Similar to seed (via Last.fm)", + ) + for item in items: + pools[i].append(item) + pool_seen.add(item.release_group_mbid.lower()) + except asyncio.TimeoutError: + logger.warning("Timeout getting Last.fm similar artists for seed %s", seed_mbid[:8]) + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get Last.fm similar artists for seed {seed_mbid[:8]}: {e}") + + await asyncio.gather(*[_process_seed(i, mbid) for i, mbid in enumerate(seed_mbids)]) + return pools + + +async def discover_by_genres( + genres: list[str], + excluded_mbids: set[str], + *, + mb_repo: MusicBrainzRepositoryProtocol, + mbid_svc: MbidResolutionService, + per_genre_limit: int = 4, +) -> list[DiscoverQueueItemLight]: + """Discover albums by genre tags via MusicBrainz tag search. + + The caller is responsible for resolving genre names (e.g. from a + ListenBrainz user profile) and passing them in directly. This keeps + the function reusable for Playlist-Seeded Discovery, Popular Radio, + and any other caller that already has a genre list. + """ + if not genres: + return [] + + top_genres = genres[:per_genre_limit] + + search_results = await asyncio.gather( + *[ + mb_repo.search_release_groups_by_tag(tag=genre, limit=8) + for genre in top_genres + ], + return_exceptions=True, + ) + + items: list[DiscoverQueueItemLight] = [] + seen: set[str] = set() + for genre, result in zip(top_genres, search_results): + if isinstance(result, Exception): + continue + for release in result: + rg_mbid = mbid_svc.normalize_mbid(getattr(release, "musicbrainz_id", None)) + if not rg_mbid: + continue + if rg_mbid in excluded_mbids or rg_mbid in seen: + continue + items.append( + mbid_svc.make_queue_item( + release_group_mbid=rg_mbid, + album_name=getattr(release, "title", "Unknown"), + artist_name=getattr(release, "artist", "Unknown") or "Unknown", + artist_mbid=getattr(release, "artist_id", "") or rg_mbid, + reason=f"Because you listen to {genre}", + ) + ) + seen.add(rg_mbid) + return items + + +async def get_artist_deep_cuts( + username: str, + excluded_mbids: set[str], + listened_mbids: set[str], + albums_per_artist: int, + *, + lb_repo: ListenBrainzRepositoryProtocol, + mbid_svc: MbidResolutionService, +) -> list[DiscoverQueueItemLight]: + """Find lesser-known releases from the user's top-played artists.""" + try: + top_release_groups = await lb_repo.get_user_top_release_groups( + username=username, + range_="this_month", + count=25, + ) + except Exception: # noqa: BLE001 + logger.warning("Failed to fetch top release groups from ListenBrainz for deep cuts") + return [] + + if not top_release_groups: + return [] + + current_top_mbids = { + rg.release_group_mbid.lower() + for rg in top_release_groups + if getattr(rg, "release_group_mbid", None) + } + + artist_seed_names: dict[str, str] = {} + for rg in top_release_groups: + rg_artist_mbids = getattr(rg, "artist_mbids", None) or [] + if not rg_artist_mbids: + continue + artist_mbid = mbid_svc.normalize_mbid(rg_artist_mbids[0]) + if not artist_mbid or artist_mbid in artist_seed_names: + continue + artist_seed_names[artist_mbid] = getattr(rg, "artist_name", "") + if len(artist_seed_names) >= 6: + break + + if not artist_seed_names: + return [] + + artist_mbid_list = list(artist_seed_names.keys()) + results = await asyncio.gather( + *[ + lb_repo.get_artist_top_release_groups( + a_mbid, + count=max(albums_per_artist + 2, 4), + ) + for a_mbid in artist_mbid_list + ], + return_exceptions=True, + ) + + items: list[DiscoverQueueItemLight] = [] + seen_rg_mbids: set[str] = set() + for a_mbid, result in zip(artist_mbid_list, results): + if isinstance(result, Exception): + continue + for rg in result: + rg_mbid = mbid_svc.normalize_mbid(rg.release_group_mbid) + if not rg_mbid: + continue + if rg_mbid in current_top_mbids or rg_mbid in listened_mbids: + continue + if rg_mbid in excluded_mbids or rg_mbid in seen_rg_mbids: + continue + + source_artist_name = artist_seed_names.get(a_mbid) or rg.artist_name + items.append( + mbid_svc.make_queue_item( + release_group_mbid=rg_mbid, + album_name=rg.release_group_name, + artist_name=rg.artist_name, + artist_mbid=a_mbid, + reason=f"More from {source_artist_name}", + ) + ) + seen_rg_mbids.add(rg_mbid) + return items + + +def round_robin_dedup_select( + pools: list[list[DiscoverQueueItemLight]], + count: int, + max_per_artist: int = 2, +) -> list[DiscoverQueueItemLight]: + """Select items round-robin across pools, deduplicating by MBID and capping per artist.""" + selected: list[DiscoverQueueItemLight] = [] + seen_mbids: set[str] = set() + artist_counts: dict[str, int] = {} + + shuffled = [list(pool) for pool in pools] + for pool in shuffled: + random.shuffle(pool) + + pool_indices = [0] * len(shuffled) + + for _ in range(count * 3): + if len(selected) >= count: + break + for pool_idx in range(len(shuffled)): + if len(selected) >= count: + break + pool = shuffled[pool_idx] + idx = pool_indices[pool_idx] + while idx < len(pool): + item = pool[idx] + idx += 1 + pool_indices[pool_idx] = idx + mbid_lower = item.release_group_mbid.lower() + artist_key = item.artist_mbid.lower() if item.artist_mbid else "" + if mbid_lower in seen_mbids: + continue + if artist_key and artist_counts.get(artist_key, 0) >= max_per_artist: + continue + selected.append(item) + seen_mbids.add(mbid_lower) + if artist_key: + artist_counts[artist_key] = artist_counts.get(artist_key, 0) + 1 + break + + return selected + + +async def get_trending_filler( + count: int, + ignored_mbids: set[str], + library_mbids: set[str], + seen_mbids: set[str] | None, + source: str, + *, + lb_repo: ListenBrainzRepositoryProtocol, + mb_repo: MusicBrainzRepositoryProtocol, + mbid_svc: MbidResolutionService, + lfm_repo: LastFmRepositoryProtocol | None = None, + is_lastfm_enabled: bool = False, +) -> list[DiscoverQueueItemLight]: + """Fetch trending/wildcard albums from LB or Last.fm as queue filler.""" + if count <= 0: + return [] + exclude = ignored_mbids | library_mbids | (seen_mbids or set()) + use_lastfm = source == "lastfm" and is_lastfm_enabled and lfm_repo is not None + target = max(count * 2, 6) + + try: + wildcards: list[DiscoverQueueItemLight] = [] + if use_lastfm: + top_artists = await lfm_repo.get_global_top_artists(limit=15) # type: ignore[union-attr] + random.shuffle(top_artists) + valid_artists = [ + a + for a in top_artists[:10] + if mbid_svc.normalize_mbid(a.mbid) != VARIOUS_ARTISTS_MBID + ] + album_fetch_results = await asyncio.gather( + *[ + lfm_repo.get_artist_top_albums( # type: ignore[union-attr] + a.name, mbid=a.mbid, limit=3 + ) + for a in valid_artists + ], + return_exceptions=True, + ) + artist_albums_pairs: list[tuple[object, list[object]]] = [] + for artist, result in zip(valid_artists, album_fetch_results): + if isinstance(result, Exception): + continue + artist_albums_pairs.append((artist, result)) + wildcards = await mbid_svc.lastfm_albums_to_queue_items( + artist_albums_pairs, + exclude=exclude, + target=target, + reason="Trending on Last.fm", + is_wildcard=True, + ) + else: + rgs = await lb_repo.get_sitewide_top_release_groups(count=25) + random.shuffle(rgs) + for rg in rgs: + if len(wildcards) >= target: + break + rg_mbid = rg.release_group_mbid + if not rg_mbid or rg_mbid.lower() in exclude: + continue + artist_mbid = rg.artist_mbids[0] if rg.artist_mbids else "" + if artist_mbid.lower() == VARIOUS_ARTISTS_MBID: + continue + wildcards.append(DiscoverQueueItemLight( + release_group_mbid=rg_mbid, + album_name=rg.release_group_name, + artist_name=rg.artist_name, + artist_mbid=artist_mbid, + cover_url=f"/api/v1/covers/release-group/{rg_mbid}?size=500", + recommendation_reason="Trending This Week", + is_wildcard=True, + in_library=False, + )) + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get wildcard albums: {e}") + wildcards = [] + + if not wildcards: + if use_lastfm: + decade_tags = ["2020s", "2010s", "2000s", "1990s", "1980s", "1970s"] + for decade in decade_tags: + if len(wildcards) >= target: + break + try: + decade_releases = await mb_repo.search_release_groups_by_tag( + tag=decade, + limit=25, + offset=0, + ) + except Exception: # noqa: BLE001 + logger.warning("Failed to search release groups for decade tag %s", decade) + continue + for release in decade_releases: + if len(wildcards) >= target: + break + rg_mbid = mbid_svc.normalize_mbid(getattr(release, "musicbrainz_id", None)) + if not rg_mbid or rg_mbid.lower() in exclude: + continue + wildcards.append(DiscoverQueueItemLight( + release_group_mbid=rg_mbid, + album_name=getattr(release, "title", "Unknown"), + artist_name=getattr(release, "artist", "Unknown") or "Unknown", + artist_mbid="", + cover_url=f"/api/v1/covers/release-group/{rg_mbid}?size=500", + recommendation_reason="Trending on Last.fm", + is_wildcard=True, + in_library=False, + )) + exclude.add(rg_mbid.lower()) + + if not wildcards: + logger.warning("Failed to populate any wildcard albums for discover queue") + + return wildcards[:count] + + +def interleave_at_positions( + base: list[DiscoverQueueItemLight], + insertions: list[DiscoverQueueItemLight], + positions: list[int] | None = None, +) -> list[DiscoverQueueItemLight]: + """Insert items from *insertions* into *base* at the given positions, appending any remainder. + + Because ``list.insert`` shifts subsequent indices, each position is + relative to the *growing* list (after prior insertions), not the + original ``base`` list. + """ + if positions is None: + positions = [2, 7] + result = list(base) + for i, wc in enumerate(insertions): + pos = positions[i] if i < len(positions) else len(result) + pos = min(pos, len(result)) + result.insert(pos, wc) + return result diff --git a/backend/services/discover/radio_service.py b/backend/services/discover/radio_service.py new file mode 100644 index 0000000..19d7b70 --- /dev/null +++ b/backend/services/discover/radio_service.py @@ -0,0 +1,265 @@ +"""DiscoverRadioService - generates radio sections seeded by artist, album, or genre.""" + +from __future__ import annotations + +import logging +import random +from typing import Any, TYPE_CHECKING + +from fastapi import HTTPException + +from api.v1.schemas.discover import RadioRequest +from api.v1.schemas.home import HomeAlbum, HomeSection +from repositories.listenbrainz_models import ListenBrainzArtist +from repositories.protocols import ( + ListenBrainzRepositoryProtocol, + MusicBrainzRepositoryProtocol, +) +from services.discover.integration_helpers import IntegrationHelpers +from services.discover.mbid_resolution_service import MbidResolutionService +from services.discover.queue_strategies import ( + build_similar_artist_pools, + queue_item_to_home_album, + round_robin_dedup_select, +) + +if TYPE_CHECKING: + from infrastructure.persistence.genre_index import GenreIndex + from services.album_discovery_service import AlbumDiscoveryService + from services.artist_discovery_service import ArtistDiscoveryService + from services.home_transformers import HomeDataTransformers + +logger = logging.getLogger(__name__) + + +class DiscoverRadioService: + def __init__( + self, + lb_repo: ListenBrainzRepositoryProtocol, + mb_repo: MusicBrainzRepositoryProtocol, + mbid_svc: MbidResolutionService, + artist_discovery: Any = None, + album_discovery: Any = None, + genre_index: Any = None, + integration: IntegrationHelpers | None = None, + transformers: Any = None, + ) -> None: + self._lb_repo = lb_repo + self._mb_repo = mb_repo + self._mbid = mbid_svc + self._artist_discovery = artist_discovery + self._album_discovery = album_discovery + self._genre_index = genre_index + self._integration = integration + self._transformers = transformers + + async def generate_radio(self, request: RadioRequest) -> HomeSection: + if not request.seed_id or not request.seed_id.strip(): + raise HTTPException(status_code=400, detail="seed_id must be non-empty") + + resolved_source = ( + self._integration.resolve_source(request.source) + if self._integration + else request.source or "listenbrainz" + ) + + if self._integration: + lb_enabled = self._integration.is_listenbrainz_enabled() + lfm_enabled = self._integration.is_lastfm_enabled() + source_available = ( + (resolved_source == "listenbrainz" and lb_enabled) + or (resolved_source == "lastfm" and lfm_enabled) + ) + if not source_available: + return HomeSection( + title="Radio", + type="albums", + items=[], + source=resolved_source, + fallback_message=f"{resolved_source} is not enabled", + ) + + library_mbids = await self._mbid.get_library_artist_mbids( + self._integration.is_lidarr_configured() if self._integration else False + ) + + count = request.count or 10 + + match request.seed_type: + case "artist": + return await self._radio_from_artist( + request.seed_id, library_mbids, count, resolved_source, + ) + case "album": + return await self._radio_from_album( + request.seed_id, library_mbids, count, resolved_source, + ) + case "genre": + return await self._radio_from_genre( + request.seed_id, library_mbids, count, resolved_source, + ) + case _: + raise ValueError(f"Unsupported seed_type: {request.seed_type}") + + async def _radio_from_artist( + self, + seed_id: str, + library_mbids: set[str], + count: int, + source: str, + ) -> HomeSection: + normalized = self._mbid.normalize_mbid(seed_id) + if not normalized: + raise HTTPException(status_code=404, detail=f"Unknown artist MBID: {seed_id}") + + artist_name = normalized + try: + rgs = await self._lb_repo.get_artist_top_release_groups(normalized, count=1) + if rgs: + artist_name = rgs[0].artist_name or normalized + except Exception: # noqa: BLE001 + logger.debug("Failed to resolve artist name for %s", normalized) + + seed_stub = ListenBrainzArtist( + artist_name=artist_name, + artist_mbids=[normalized], + listen_count=0, + ) + + pools = await build_similar_artist_pools( + [seed_stub], + excluded_mbids=library_mbids, + similar_limit=15, + albums_per=3, + lb_repo=self._lb_repo, + mbid_svc=self._mbid, + ) + selected = round_robin_dedup_select(pools, count) + albums = [queue_item_to_home_album(item) for item in selected] + + return HomeSection( + title=f"Radio: {artist_name}", + type="albums", + items=albums, + source=source, + radio_seed_type="artist", + radio_seed_id=normalized, + ) + + async def _radio_from_album( + self, + seed_id: str, + library_mbids: set[str], + count: int, + source: str, + ) -> HomeSection: + normalized = self._mbid.normalize_mbid(seed_id) + if not normalized: + raise HTTPException(status_code=404, detail=f"Unknown album MBID: {seed_id}") + + rg_info = await self._mb_repo.get_release_group(normalized) + if not rg_info: + raise HTTPException(status_code=404, detail=f"Release group not found: {seed_id}") + + artist_mbid = rg_info.artist_id + album_name = rg_info.title + + if self._album_discovery is None: + raise ValueError("Album radio requires album_discovery service but it is not configured") + + similar_resp = await self._album_discovery.get_similar_albums( + album_mbid=normalized, artist_mbid=artist_mbid, count=count, + ) + more_resp = await self._album_discovery.get_more_by_artist( + artist_mbid=artist_mbid, exclude_album_mbid=normalized, count=count, + ) + + seen: set[str] = {normalized.lower()} + albums: list[HomeAlbum] = [] + + for album in similar_resp.albums: + mbid_lower = album.musicbrainz_id.lower() + if mbid_lower in seen: + continue + seen.add(mbid_lower) + albums.append(HomeAlbum( + name=album.title, + mbid=album.musicbrainz_id, + artist_name=album.artist_name, + artist_mbid=album.artist_id, + image_url=f"/api/v1/covers/release-group/{album.musicbrainz_id}?size=500", + in_library=album.in_library, + requested=album.requested, + )) + + for album in more_resp.albums: + if len(albums) >= count: + break + mbid_lower = album.musicbrainz_id.lower() + if mbid_lower in seen: + continue + seen.add(mbid_lower) + albums.append(HomeAlbum( + name=album.title, + mbid=album.musicbrainz_id, + artist_name=album.artist_name, + artist_mbid=album.artist_id, + image_url=f"/api/v1/covers/release-group/{album.musicbrainz_id}?size=500", + in_library=album.in_library, + requested=album.requested, + )) + + return HomeSection( + title=f"Radio: {album_name}", + type="albums", + items=albums[:count], + source=source, + radio_seed_type="album", + radio_seed_id=normalized, + ) + + async def _radio_from_genre( + self, + seed_id: str, + library_mbids: set[str], + count: int, + source: str, + ) -> HomeSection: + genre_artists = await self._genre_index.get_artists_for_genres([seed_id]) + genre_key = seed_id.strip().lower() + artist_mbids = genre_artists.get(genre_key, []) + + if not artist_mbids: + raise HTTPException(status_code=422, detail=f"Unknown genre tag: {seed_id}") + + sample_size = min(len(artist_mbids), 5) + sampled = random.sample(artist_mbids, sample_size) + + seeds = [ + ListenBrainzArtist( + artist_name=mbid, + artist_mbids=[mbid], + listen_count=0, + ) + for mbid in sampled + ] + + pools = await build_similar_artist_pools( + seeds, + excluded_mbids=library_mbids, + similar_limit=10, + albums_per=3, + lb_repo=self._lb_repo, + mbid_svc=self._mbid, + ) + selected = round_robin_dedup_select(pools, count) + albums = [queue_item_to_home_album(item) for item in selected] + + return HomeSection( + title=f"Radio: {seed_id.title()}", + type="albums", + items=albums, + source=source, + radio_seed_type="genre", + radio_seed_id=seed_id, + ) diff --git a/backend/services/playlist_service.py b/backend/services/playlist_service.py index dc628bd..fc103ea 100644 --- a/backend/services/playlist_service.py +++ b/backend/services/playlist_service.py @@ -75,10 +75,17 @@ def _fuzzy_name_match(name1: str, name2: str) -> bool: class PlaylistService: - def __init__(self, repo: PlaylistRepository, cache_dir: Path, cache: Optional[CacheInterface] = None): + def __init__( + self, + repo: PlaylistRepository, + cache_dir: Path, + cache: Optional[CacheInterface] = None, + genre_index: Any = None, + ): self._repo = AsyncPlaylistRepository(repo) self._cover_dir = cache_dir / "covers" / "playlists" self._cache = cache + self._genre_index = genre_index async def create_playlist(self, name: str, *, source_ref: str | None = None) -> PlaylistRecord: @@ -257,6 +264,28 @@ class PlaylistService: async def get_tracks(self, playlist_id: str) -> list[PlaylistTrackRecord]: return await self._repo.get_tracks(playlist_id) + async def analyse_playlist_profile( + self, playlist_id: str, + ) -> "PlaylistProfile | None": + from api.v1.schemas.discover import PlaylistProfile + + playlist = await self._repo.get_playlist(playlist_id) + if playlist is None: + return None + + tracks = await self._repo.get_tracks(playlist_id) + artist_mbids = list({t.artist_id for t in tracks if t.artist_id}) + + genre_distribution: dict[str, list[str]] = {} + if artist_mbids and self._genre_index is not None: + genre_distribution = await self._genre_index.get_genres_for_artists(artist_mbids) + + return PlaylistProfile( + artist_mbids=artist_mbids, + genre_distribution=genre_distribution, + track_count=len(tracks), + ) + async def check_track_membership( self, tracks: list[tuple[str, str, str]], ) -> dict[str, list[int]]: diff --git a/backend/tests/infrastructure/test_genre_index.py b/backend/tests/infrastructure/test_genre_index.py new file mode 100644 index 0000000..8d711c0 --- /dev/null +++ b/backend/tests/infrastructure/test_genre_index.py @@ -0,0 +1,296 @@ +"""Tests for GenreIndex enrichment methods.""" + +import sqlite3 +import threading +from typing import Any + +import pytest + +from infrastructure.persistence.genre_index import GenreIndex + + +class _NoCloseConnection: + """Wrapper that prevents .close() from destroying the in-memory database.""" + + def __init__(self, conn: sqlite3.Connection) -> None: + self._conn = conn + + def close(self) -> None: + pass # intentionally a no-op + + def __getattr__(self, name: str) -> Any: + return getattr(self._conn, name) + + +class InMemoryGenreIndex(GenreIndex): + """GenreIndex backed by an in-memory SQLite database for testing.""" + + def __init__(self) -> None: + self.db_path = None # type: ignore[assignment] + self._write_lock = threading.Lock() + self._real_conn: sqlite3.Connection | None = None + self._ensure_tables() + # Create library_artists table that GenreIndex JOINs against + # (normally owned by LibraryDB but lives in the same SQLite file) + conn = self._connect() + conn.execute( + """ + CREATE TABLE IF NOT EXISTS library_artists ( + mbid_lower TEXT PRIMARY KEY, + name TEXT NOT NULL, + raw_json TEXT NOT NULL DEFAULT '{}' + ) + """ + ) + conn.commit() + + def _connect(self) -> sqlite3.Connection: + if self._real_conn is None: + conn = sqlite3.connect(":memory:", check_same_thread=False) + conn.row_factory = sqlite3.Row + self._real_conn = conn + return _NoCloseConnection(self._real_conn) # type: ignore[return-value] + + def close(self) -> None: + if self._real_conn: + self._real_conn.close() + self._real_conn = None + + +def _seed_data( + index: InMemoryGenreIndex, + artists: list[tuple[str, str]], + genre_map: dict[str, list[str]], +) -> None: + """Seed library_artists and artist genre data synchronously. + + artists: [(mbid, name), ...] + genre_map: {mbid: [genre1, genre2, ...]} + """ + import json + + conn = index._connect() + + for mbid, name in artists: + conn.execute( + "INSERT OR REPLACE INTO library_artists (mbid_lower, name) VALUES (?, ?)", + (mbid.lower(), name), + ) + + for mbid, genres in genre_map.items(): + mbid_lower = mbid.lower() + conn.execute( + """ + INSERT INTO artist_genres (artist_mbid_lower, artist_mbid, genres_json) + VALUES (?, ?, ?) + ON CONFLICT(artist_mbid_lower) DO UPDATE SET + artist_mbid = excluded.artist_mbid, + genres_json = excluded.genres_json + """, + (mbid_lower, mbid, json.dumps(genres)), + ) + conn.execute( + "DELETE FROM artist_genre_lookup WHERE artist_mbid_lower = ?", + (mbid_lower,), + ) + for genre in genres: + conn.execute( + "INSERT OR REPLACE INTO artist_genre_lookup (artist_mbid_lower, genre_lower) VALUES (?, ?)", + (mbid_lower, genre.strip().lower()), + ) + + conn.commit() + + +@pytest.fixture +def genre_index() -> InMemoryGenreIndex: + idx = InMemoryGenreIndex() + yield idx # type: ignore[misc] + idx.close() + + +class TestGetTopGenres: + @pytest.mark.asyncio + async def test_returns_genres_ranked_by_artist_count(self, genre_index: InMemoryGenreIndex) -> None: + """AC #18: genres ranked by library artist count descending.""" + _seed_data(genre_index, [ + ("a1", "Artist1"), ("a2", "Artist2"), ("a3", "Artist3"), + ], { + "a1": ["Rock", "Pop"], + "a2": ["Rock", "Jazz"], + "a3": ["Rock"], + }) + result = await genre_index.get_top_genres(limit=10) + names = [g for g, _ in result] + counts = [c for _, c in result] + assert names[0] == "rock" + assert counts[0] == 3 + assert counts[0] >= counts[1] + assert len(result) == 3 + + @pytest.mark.asyncio + async def test_respects_limit(self, genre_index: InMemoryGenreIndex) -> None: + """Limit parameter caps result count.""" + _seed_data(genre_index, [ + ("a1", "Artist1"), ("a2", "Artist2"), + ], { + "a1": ["Rock", "Pop", "Jazz"], + "a2": ["Rock", "Metal"], + }) + result = await genre_index.get_top_genres(limit=2) + assert len(result) == 2 + + @pytest.mark.asyncio + async def test_empty_library_returns_empty(self, genre_index: InMemoryGenreIndex) -> None: + """No library artists → empty result.""" + result = await genre_index.get_top_genres() + assert result == [] + + @pytest.mark.asyncio + async def test_ties_broken_alphabetically(self, genre_index: InMemoryGenreIndex) -> None: + """Genres with equal artist counts are ordered alphabetically.""" + _seed_data(genre_index, [ + ("a1", "Artist1"), + ], { + "a1": ["Pop", "Jazz"], + }) + result = await genre_index.get_top_genres(limit=10) + names = [g for g, _ in result] + assert names == ["jazz", "pop"] + + +class TestGetGenreArtistCounts: + @pytest.mark.asyncio + async def test_returns_correct_counts(self, genre_index: InMemoryGenreIndex) -> None: + _seed_data(genre_index, [ + ("a1", "Artist1"), ("a2", "Artist2"), ("a3", "Artist3"), + ], { + "a1": ["Rock", "Pop"], + "a2": ["Rock"], + "a3": ["Pop", "Jazz"], + }) + result = await genre_index.get_genre_artist_counts(["Rock", "Pop", "Jazz"]) + assert result["rock"] == 2 + assert result["pop"] == 2 + assert result["jazz"] == 1 + + @pytest.mark.asyncio + async def test_empty_input_returns_empty(self, genre_index: InMemoryGenreIndex) -> None: + result = await genre_index.get_genre_artist_counts([]) + assert result == {} + + @pytest.mark.asyncio + async def test_missing_genre_absent_from_result(self, genre_index: InMemoryGenreIndex) -> None: + """Genres with zero library artists are not in the returned dict.""" + _seed_data(genre_index, [("a1", "Artist1")], {"a1": ["Rock"]}) + result = await genre_index.get_genre_artist_counts(["Rock", "Classical"]) + assert result.get("rock") == 1 + assert "classical" not in result + + +class TestGetArtistsForGenres: + @pytest.mark.asyncio + async def test_returns_correct_artists_per_genre(self, genre_index: InMemoryGenreIndex) -> None: + _seed_data(genre_index, [ + ("a1", "Artist1"), ("a2", "Artist2"), ("a3", "Artist3"), + ], { + "a1": ["Rock", "Pop"], + "a2": ["Rock"], + "a3": ["Jazz"], + }) + result = await genre_index.get_artists_for_genres(["Rock", "Jazz"]) + assert sorted(result["rock"]) == sorted(["a1", "a2"]) + assert result["jazz"] == ["a3"] + + @pytest.mark.asyncio + async def test_empty_input_returns_empty(self, genre_index: InMemoryGenreIndex) -> None: + result = await genre_index.get_artists_for_genres([]) + assert result == {} + + @pytest.mark.asyncio + async def test_missing_genre_not_in_result(self, genre_index: InMemoryGenreIndex) -> None: + """A genre not in the index should not appear in the result dict.""" + _seed_data(genre_index, [("a1", "Artist1")], {"a1": ["Rock"]}) + result = await genre_index.get_artists_for_genres(["Classical"]) + assert "classical" not in result + + +class TestGetGenresForArtists: + @pytest.mark.asyncio + async def test_returns_genres_for_known_artists(self, genre_index: InMemoryGenreIndex) -> None: + _seed_data(genre_index, [ + ("a1", "Artist1"), ("a2", "Artist2"), + ], { + "a1": ["Rock", "Pop"], + "a2": ["Jazz"], + }) + result = await genre_index.get_genres_for_artists(["a1", "a2"]) + assert result["a1"] == ["Rock", "Pop"] + assert result["a2"] == ["Jazz"] + + @pytest.mark.asyncio + async def test_empty_input_returns_empty(self, genre_index: InMemoryGenreIndex) -> None: + result = await genre_index.get_genres_for_artists([]) + assert result == {} + + @pytest.mark.asyncio + async def test_corrupt_json_skipped(self, genre_index: InMemoryGenreIndex) -> None: + """Artist with corrupt genres_json is silently skipped.""" + _seed_data(genre_index, [("a1", "Artist1")], {"a1": ["Rock"]}) + conn = genre_index._connect() + conn.execute( + "UPDATE artist_genres SET genres_json = 'NOT-JSON' WHERE artist_mbid_lower = 'a1'" + ) + conn.commit() + result = await genre_index.get_genres_for_artists(["a1"]) + assert "a1" not in result + + +class TestGetUnderrepresentedGenres: + @pytest.mark.asyncio + async def test_returns_genres_below_threshold(self, genre_index: InMemoryGenreIndex) -> None: + _seed_data(genre_index, [ + ("a1", "Artist1"), ("a2", "Artist2"), ("a3", "Artist3"), + ], { + "a1": ["Rock", "Jazz"], + "a2": ["Rock", "Blues"], + "a3": ["Rock"], + }) + result = await genre_index.get_underrepresented_genres([], threshold=2) + assert "rock" not in result + assert "jazz" in result + assert "blues" in result + + @pytest.mark.asyncio + async def test_excludes_known_genres(self, genre_index: InMemoryGenreIndex) -> None: + _seed_data(genre_index, [ + ("a1", "Artist1"), ("a2", "Artist2"), + ], { + "a1": ["Rock", "Jazz"], + "a2": ["Rock"], + }) + result = await genre_index.get_underrepresented_genres(["Jazz"], threshold=2) + assert "jazz" not in result + + @pytest.mark.asyncio + async def test_empty_library_returns_empty(self, genre_index: InMemoryGenreIndex) -> None: + result = await genre_index.get_underrepresented_genres([], threshold=2) + assert result == [] + + @pytest.mark.asyncio + async def test_ordered_by_count_descending(self, genre_index: InMemoryGenreIndex) -> None: + """More-represented-but-still-underrepresented genres come first.""" + _seed_data(genre_index, [ + ("a1", "Artist1"), ("a2", "Artist2"), ("a3", "Artist3"), + ("a4", "Artist4"), ("a5", "Artist5"), + ], { + "a1": ["Rock", "Pop", "Jazz"], + "a2": ["Rock", "Pop"], + "a3": ["Rock"], + "a4": ["Pop"], + "a5": ["Jazz"], + }) + result = await genre_index.get_underrepresented_genres([], threshold=4) + assert len(result) >= 2 + if "jazz" in result and "rock" in result: + assert result.index("rock") < result.index("jazz") diff --git a/backend/tests/routes/test_discover_radio_routes.py b/backend/tests/routes/test_discover_radio_routes.py new file mode 100644 index 0000000..319411f --- /dev/null +++ b/backend/tests/routes/test_discover_radio_routes.py @@ -0,0 +1,149 @@ +"""Route-level tests for POST /api/v1/discover/radio.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from api.v1.schemas.home import HomeAlbum, HomeSection +from api.v1.routes.discover import router +from core.dependencies import get_discover_service + + +def _make_radio_section(source: str = "listenbrainz", count: int = 3) -> HomeSection: + return HomeSection( + title="Radio: Test Artist", + type="albums", + items=[ + HomeAlbum( + name=f"Album {i}", + mbid=f"rg-mbid-{i}", + artist_name="Test Artist", + artist_mbid="artist-mbid-1", + image_url=f"/api/v1/covers/release-group/rg-mbid-{i}?size=500", + ) + for i in range(count) + ], + source=source, + ) + + +@pytest.fixture +def mock_discover_service(): + mock = AsyncMock() + mock.generate_radio = AsyncMock(return_value=_make_radio_section()) + mock.resolve_source = MagicMock(side_effect=lambda s: s or "listenbrainz") + return mock + + +@pytest.fixture +def client(mock_discover_service): + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_discover_service] = lambda: mock_discover_service + return TestClient(app) + + +class TestPostRadioValidArtistSeed: + def test_returns_200(self, client, mock_discover_service) -> None: + resp = client.post( + "/discover/radio", + json={"seed_type": "artist", "seed_id": "valid-mbid"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["type"] == "albums" + assert data["title"].startswith("Radio: ") + assert len(data["items"]) == 3 + + +class TestPostRadioInvalidSeedType: + def test_returns_422(self, client) -> None: + resp = client.post( + "/discover/radio", + json={"seed_type": "invalid", "seed_id": "x"}, + ) + assert resp.status_code == 422 + + +class TestPostRadioEmptySeedId: + def test_returns_400(self, client, mock_discover_service) -> None: + mock_discover_service.generate_radio = AsyncMock( + side_effect=HTTPException(status_code=400, detail="seed_id must be non-empty"), + ) + resp = client.post( + "/discover/radio", + json={"seed_type": "artist", "seed_id": ""}, + ) + assert resp.status_code == 400 + + +class TestPostRadioUnknownArtistMbid: + def test_returns_404(self, client, mock_discover_service) -> None: + mock_discover_service.generate_radio = AsyncMock( + side_effect=HTTPException(status_code=404, detail="Unknown artist MBID"), + ) + resp = client.post( + "/discover/radio", + json={"seed_type": "artist", "seed_id": "unknown-mbid"}, + ) + assert resp.status_code == 404 + + +class TestPostRadioUnknownGenreTag: + def test_returns_422(self, client, mock_discover_service) -> None: + mock_discover_service.generate_radio = AsyncMock( + side_effect=HTTPException(status_code=422, detail="Unknown genre tag"), + ) + resp = client.post( + "/discover/radio", + json={"seed_type": "genre", "seed_id": "nonexistent"}, + ) + assert resp.status_code == 422 + + +class TestPostRadioWithOptionalCount: + def test_returns_200_with_count(self, client, mock_discover_service) -> None: + mock_discover_service.generate_radio = AsyncMock( + return_value=_make_radio_section(count=5), + ) + resp = client.post( + "/discover/radio", + json={"seed_type": "artist", "seed_id": "x", "count": 5}, + ) + assert resp.status_code == 200 + data = resp.json() + assert len(data["items"]) == 5 + + +class TestPostRadioWithOptionalSource: + def test_returns_200_with_source(self, client, mock_discover_service) -> None: + mock_discover_service.generate_radio = AsyncMock( + return_value=_make_radio_section(source="lastfm"), + ) + resp = client.post( + "/discover/radio", + json={"seed_type": "artist", "seed_id": "x", "source": "lastfm"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["source"] == "lastfm" + + +class TestPostRadioMissingSeedType: + def test_returns_422(self, client) -> None: + resp = client.post( + "/discover/radio", + json={"seed_id": "x"}, + ) + assert resp.status_code == 422 + + +class TestPostRadioInvalidSource: + def test_returns_422_for_invalid_source(self, client) -> None: + resp = client.post( + "/discover/radio", + json={"seed_type": "artist", "seed_id": "x", "source": "spotify"}, + ) + assert resp.status_code == 422 diff --git a/backend/tests/routes/test_playlist_suggestions_routes.py b/backend/tests/routes/test_playlist_suggestions_routes.py new file mode 100644 index 0000000..ff95e99 --- /dev/null +++ b/backend/tests/routes/test_playlist_suggestions_routes.py @@ -0,0 +1,157 @@ +"""Route-level tests for POST /api/v1/discover/playlist-suggestions.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from api.v1.schemas.discover import ( + PlaylistProfile, + PlaylistSuggestionsResponse, +) +from api.v1.schemas.home import HomeAlbum, HomeSection +from api.v1.routes.discover import router +from core.dependencies import get_discover_service + + +def _make_suggestions_response( + playlist_id: str = "pl-1", + source: str = "listenbrainz", + count: int = 3, +) -> PlaylistSuggestionsResponse: + section = HomeSection( + title="Suggestions for your playlist", + type="albums", + items=[ + HomeAlbum( + name=f"Album {i}", + mbid=f"rg-mbid-{i}", + artist_name="Test Artist", + artist_mbid="artist-mbid-1", + image_url=f"/api/v1/covers/release-group/rg-mbid-{i}?size=500", + ) + for i in range(count) + ], + source=source, + ) + return PlaylistSuggestionsResponse( + suggestions=section, + playlist_id=playlist_id, + profile=PlaylistProfile( + artist_mbids=["artist-mbid-1"], + track_count=10, + ), + ) + + +@pytest.fixture +def mock_discover_service(): + mock = AsyncMock() + mock.get_playlist_suggestions = AsyncMock( + return_value=_make_suggestions_response(), + ) + mock.resolve_source = MagicMock(side_effect=lambda s: s or "listenbrainz") + return mock + + +@pytest.fixture +def client(mock_discover_service): + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_discover_service] = lambda: mock_discover_service + return TestClient(app) + + +class TestPostPlaylistSuggestionsValidReturns200: + def test_returns_200(self, client, mock_discover_service) -> None: + resp = client.post( + "/discover/playlist-suggestions", + json={"playlist_id": "pl-1"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert "suggestions" in data + assert "playlist_id" in data + assert "profile" in data + assert data["playlist_id"] == "pl-1" + + +class TestPostPlaylistSuggestionsWithCountReturns200: + def test_returns_200_with_count(self, client, mock_discover_service) -> None: + mock_discover_service.get_playlist_suggestions = AsyncMock( + return_value=_make_suggestions_response(count=5), + ) + resp = client.post( + "/discover/playlist-suggestions", + json={"playlist_id": "pl-1", "count": 5}, + ) + assert resp.status_code == 200 + data = resp.json() + assert len(data["suggestions"]["items"]) == 5 + + +class TestPostPlaylistSuggestionsWithSourceReturns200: + def test_returns_200_with_source(self, client, mock_discover_service) -> None: + resp = client.post( + "/discover/playlist-suggestions", + json={"playlist_id": "pl-1", "source": "listenbrainz"}, + ) + assert resp.status_code == 200 + + +class TestPostPlaylistSuggestionsInvalidSourceReturns422: + def test_returns_422(self, client) -> None: + resp = client.post( + "/discover/playlist-suggestions", + json={"playlist_id": "pl-1", "source": "invalid"}, + ) + assert resp.status_code == 422 + + +class TestPostPlaylistSuggestionsUnknownPlaylistReturns404: + def test_returns_404(self, client, mock_discover_service) -> None: + mock_discover_service.get_playlist_suggestions = AsyncMock( + side_effect=HTTPException(status_code=404, detail="Playlist not found"), + ) + resp = client.post( + "/discover/playlist-suggestions", + json={"playlist_id": "unknown"}, + ) + assert resp.status_code == 404 + + +class TestPostPlaylistSuggestionsEmptyProfileReturns422: + def test_returns_422(self, client, mock_discover_service) -> None: + mock_discover_service.get_playlist_suggestions = AsyncMock( + side_effect=HTTPException( + status_code=422, + detail="Playlist has no artist data for discovery", + ), + ) + resp = client.post( + "/discover/playlist-suggestions", + json={"playlist_id": "pl-empty"}, + ) + assert resp.status_code == 422 + + +class TestPostPlaylistSuggestionsMissingPlaylistIdReturns422: + def test_returns_422(self, client) -> None: + resp = client.post( + "/discover/playlist-suggestions", + json={"count": 5}, + ) + assert resp.status_code == 422 + + +class TestPostPlaylistSuggestionsEmptyPlaylistIdReturns404: + def test_returns_404(self, client, mock_discover_service) -> None: + mock_discover_service.get_playlist_suggestions = AsyncMock( + side_effect=HTTPException(status_code=404, detail="Playlist not found"), + ) + resp = client.post( + "/discover/playlist-suggestions", + json={"playlist_id": ""}, + ) + assert resp.status_code == 404 diff --git a/backend/tests/schemas/__init__.py b/backend/tests/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/schemas/test_discover_schemas.py b/backend/tests/schemas/test_discover_schemas.py new file mode 100644 index 0000000..ab48359 --- /dev/null +++ b/backend/tests/schemas/test_discover_schemas.py @@ -0,0 +1,169 @@ +import json + +import msgspec +import pytest + +from api.v1.schemas.advanced_settings import AdvancedSettings, AdvancedSettingsFrontend +from api.v1.schemas.discover import ( + DiscoverResponse, + PlaylistProfile, + PlaylistSuggestionsRequest, + PlaylistSuggestionsResponse, + RadioRequest, +) +from api.v1.schemas.home import HomeSection + + +class TestDiscoverResponseNewFields: + def test_default_daily_mixes_is_empty_list(self) -> None: + resp = DiscoverResponse() + assert resp.daily_mixes == [] + + def test_default_radio_sections_is_empty_list(self) -> None: + resp = DiscoverResponse() + assert resp.radio_sections == [] + + def test_default_discover_picks_is_none(self) -> None: + resp = DiscoverResponse() + assert resp.discover_picks is None + + def test_default_unexplored_genres_is_none(self) -> None: + resp = DiscoverResponse() + assert resp.unexplored_genres is None + + def test_roundtrip_with_new_fields(self) -> None: + section = HomeSection(title="Test", type="albums") + resp = DiscoverResponse( + daily_mixes=[section], + radio_sections=[section], + discover_picks=section, + unexplored_genres=section, + ) + data = json.loads(msgspec.json.encode(resp)) + assert len(data["daily_mixes"]) == 1 + assert data["discover_picks"]["title"] == "Test" + assert data["unexplored_genres"]["title"] == "Test" + assert len(data["radio_sections"]) == 1 + + def test_roundtrip_preserves_existing_fields(self) -> None: + resp = DiscoverResponse(refreshing=True, discover_queue_enabled=False) + data = json.loads(msgspec.json.encode(resp)) + assert data["refreshing"] is True + assert data["discover_queue_enabled"] is False + assert data["daily_mixes"] == [] + assert data["discover_picks"] is None + + +class TestRadioRequest: + def test_required_fields(self) -> None: + req = RadioRequest(seed_type="artist", seed_id="abc-123") + assert req.seed_type == "artist" + assert req.seed_id == "abc-123" + assert req.count == 10 + assert req.source is None + + def test_roundtrip(self) -> None: + req = RadioRequest(seed_type="genre", seed_id="rock", count=20, source="listenbrainz") + data = msgspec.json.encode(req) + decoded = msgspec.json.decode(data, type=RadioRequest) + assert decoded.seed_type == "genre" + assert decoded.count == 20 + + def test_invalid_seed_type_rejected(self) -> None: + with pytest.raises(msgspec.ValidationError): + msgspec.json.decode(b'{"seed_type":"invalid","seed_id":"x"}', type=RadioRequest) + + +class TestPlaylistProfile: + def test_defaults(self) -> None: + profile = PlaylistProfile() + assert profile.artist_mbids == [] + assert profile.genre_distribution == {} + assert profile.track_count == 0 + + def test_roundtrip(self) -> None: + profile = PlaylistProfile( + artist_mbids=["a", "b"], + genre_distribution={"rock": ["a"], "jazz": ["b"]}, + track_count=42, + ) + data = msgspec.json.encode(profile) + decoded = msgspec.json.decode(data, type=PlaylistProfile) + assert decoded.artist_mbids == ["a", "b"] + assert decoded.genre_distribution["rock"] == ["a"] + + +class TestPlaylistSuggestionsRequest: + def test_required_fields(self) -> None: + req = PlaylistSuggestionsRequest(playlist_id="pl-1") + assert req.playlist_id == "pl-1" + assert req.count == 10 + assert req.source is None + + def test_roundtrip(self) -> None: + req = PlaylistSuggestionsRequest(playlist_id="pl-1", count=5) + data = msgspec.json.encode(req) + decoded = msgspec.json.decode(data, type=PlaylistSuggestionsRequest) + assert decoded.count == 5 + + def test_source_lastfm_roundtrip(self) -> None: + req = PlaylistSuggestionsRequest(playlist_id="pl-1", source="lastfm") + data = msgspec.json.encode(req) + decoded = msgspec.json.decode(data, type=PlaylistSuggestionsRequest) + assert decoded.source == "lastfm" + + def test_source_listenbrainz_roundtrip(self) -> None: + req = PlaylistSuggestionsRequest(playlist_id="pl-1", source="listenbrainz") + data = msgspec.json.encode(req) + decoded = msgspec.json.decode(data, type=PlaylistSuggestionsRequest) + assert decoded.source == "listenbrainz" + + def test_invalid_source_rejected(self) -> None: + with pytest.raises(msgspec.ValidationError): + msgspec.json.decode( + b'{"playlist_id":"x","source":"invalid"}', + type=PlaylistSuggestionsRequest, + ) + + +class TestPlaylistSuggestionsResponse: + def test_roundtrip(self) -> None: + section = HomeSection(title="Sug", type="albums") + profile = PlaylistProfile(track_count=10) + resp = PlaylistSuggestionsResponse(suggestions=section, playlist_id="pl-1", profile=profile) + data = json.loads(msgspec.json.encode(resp)) + assert data["playlist_id"] == "pl-1" + assert data["profile"]["track_count"] == 10 + assert data["suggestions"]["title"] == "Sug" + + +class TestAdvancedSettingsDiscoverPicks: + def test_defaults_on_internal(self) -> None: + settings = AdvancedSettings() + assert settings.discover_picks_genre_affinity_weight == 0.7 + assert settings.discover_picks_count == 12 + + def test_defaults_on_user_facing(self) -> None: + uf = AdvancedSettingsFrontend() + assert uf.discover_picks_genre_affinity_weight == 0.7 + assert uf.discover_picks_count == 12 + + def test_roundtrip_internal_to_user_to_internal(self) -> None: + original = AdvancedSettings( + discover_picks_genre_affinity_weight=0.5, + discover_picks_count=20, + ) + uf = AdvancedSettingsFrontend.from_backend(original) + assert uf.discover_picks_genre_affinity_weight == 0.5 + assert uf.discover_picks_count == 20 + back = uf.to_backend() + assert back.discover_picks_genre_affinity_weight == 0.5 + assert back.discover_picks_count == 20 + + def test_validation_rejects_out_of_range_affinity(self) -> None: + with pytest.raises(msgspec.ValidationError): + AdvancedSettings(discover_picks_genre_affinity_weight=1.5) + + def test_validation_rejects_out_of_range_count(self) -> None: + with pytest.raises(msgspec.ValidationError): + AdvancedSettings(discover_picks_count=0) diff --git a/backend/tests/services/test_daily_mix.py b/backend/tests/services/test_daily_mix.py new file mode 100644 index 0000000..8b471f9 --- /dev/null +++ b/backend/tests/services/test_daily_mix.py @@ -0,0 +1,403 @@ +"""Tests for the Daily Mix section builder in DiscoverHomepageService.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from api.v1.schemas.discover import DiscoverResponse, DiscoverQueueItemLight +from api.v1.schemas.home import HomeSection, HomeAlbum +from api.v1.schemas.settings import ( + ListenBrainzConnectionSettings, + LastFmConnectionSettings, + PrimaryMusicSourceSettings, +) +from services.discover.homepage_service import ( + DiscoverHomepageService, + DAILY_MIX_CACHE_TTL, +) +from services.discover.integration_helpers import IntegrationHelpers + + +def _make_lb_settings( + enabled: bool = True, username: str = "lbuser" +) -> ListenBrainzConnectionSettings: + return ListenBrainzConnectionSettings( + user_token="tok", username=username, enabled=enabled, + ) + + +def _make_lfm_settings( + enabled: bool = True, username: str = "lfmuser" +) -> LastFmConnectionSettings: + return LastFmConnectionSettings( + api_key="key", shared_secret="secret", session_key="sk", + username=username, enabled=enabled, + ) + + +def _make_prefs( + lb_enabled: bool = True, + lfm_enabled: bool = False, + primary_source: str = "listenbrainz", +) -> MagicMock: + prefs = MagicMock() + prefs.get_listenbrainz_connection.return_value = _make_lb_settings(enabled=lb_enabled) + prefs.get_lastfm_connection.return_value = _make_lfm_settings(enabled=lfm_enabled) + prefs.is_lastfm_enabled.return_value = lfm_enabled + prefs.get_primary_music_source.return_value = PrimaryMusicSourceSettings(source=primary_source) + + jf_settings = MagicMock() + jf_settings.enabled = False + jf_settings.jellyfin_url = "" + jf_settings.api_key = "" + prefs.get_jellyfin_connection.return_value = jf_settings + + lidarr = MagicMock() + lidarr.lidarr_url = "" + lidarr.lidarr_api_key = "" + prefs.get_lidarr_connection.return_value = lidarr + + yt = MagicMock() + yt.enabled = False + yt.api_key = "" + prefs.get_youtube_connection.return_value = yt + + lf = MagicMock() + lf.enabled = False + lf.music_path = "" + prefs.get_local_files_connection.return_value = lf + + adv = MagicMock() + adv.discover_queue_size = 10 + adv.discover_queue_ttl = 3600 + adv.discover_queue_seed_artists = 3 + adv.discover_queue_wildcard_slots = 2 + adv.discover_queue_similar_artists_limit = 15 + adv.discover_queue_albums_per_similar = 3 + adv.discover_queue_enrich_ttl = 3600 + adv.discover_queue_lastfm_mbid_max_lookups = 10 + prefs.get_advanced_settings.return_value = adv + + return prefs + + +def _make_genre_index( + top_genres: list[tuple[str, int]] | None = None, + artists_by_genre: dict[str, list[str]] | None = None, + albums_by_genre: list[dict] | None = None, +) -> AsyncMock: + genre_index = AsyncMock() + genre_index.get_top_genres = AsyncMock(return_value=top_genres or []) + genre_index.get_artists_for_genres = AsyncMock(return_value=artists_by_genre or {}) + genre_index.get_albums_by_genre = AsyncMock(return_value=albums_by_genre or []) + return genre_index + + +def _make_cache() -> AsyncMock: + cache = AsyncMock() + cache.get = AsyncMock(return_value=None) + cache.set = AsyncMock() + return cache + + +def _make_pool_items(count: int, prefix: str = "album") -> list[DiscoverQueueItemLight]: + return [ + DiscoverQueueItemLight( + release_group_mbid=f"rg-{prefix}-{i}", + album_name=f"{prefix.title()} {i}", + artist_name=f"Artist {i}", + artist_mbid=f"artist-{prefix}-{i}", + recommendation_reason=f"Similar to seed", + ) + for i in range(count) + ] + + +def _make_service( + genre_index: AsyncMock | None = None, + cache: AsyncMock | None = None, +) -> DiscoverHomepageService: + lb_repo = AsyncMock() + jf_repo = AsyncMock() + lidarr_repo = AsyncMock() + mb_repo = AsyncMock() + prefs = _make_prefs() + integration = IntegrationHelpers(prefs) + mbid_resolution = MagicMock() + + return DiscoverHomepageService( + listenbrainz_repo=lb_repo, + jellyfin_repo=jf_repo, + lidarr_repo=lidarr_repo, + musicbrainz_repo=mb_repo, + integration=integration, + mbid_resolution=mbid_resolution, + memory_cache=cache, + genre_index=genre_index, + ) + + +def _make_top_genres(count: int) -> list[tuple[str, int]]: + genres = ["rock", "pop", "jazz", "electronic", "metal", "hip hop", "classical", "blues"] + return [(genres[i % len(genres)], 10 - i) for i in range(count)] + + +def _make_artists_by_genre(genres: list[str], artists_per: int = 5) -> dict[str, list[str]]: + result: dict[str, list[str]] = {} + counter = 0 + for genre in genres: + result[genre] = [f"artist-mbid-{counter + j}" for j in range(artists_per)] + counter += artists_per + return result + + +class TestDailyMixReturnsEmptyWhenGenreIndexIsNone: + @pytest.mark.asyncio + async def test_returns_empty_when_genre_index_is_none(self) -> None: + service = _make_service(genre_index=None) + result = await service._build_daily_mix_sections("listenbrainz", set()) + assert result == [] + + +class TestDailyMixReturnsEmptyWhenNoGenres: + @pytest.mark.asyncio + async def test_returns_empty_when_no_genres(self) -> None: + genre_index = _make_genre_index(top_genres=[]) + cache = _make_cache() + service = _make_service(genre_index=genre_index, cache=cache) + result = await service._build_daily_mix_sections("listenbrainz", set()) + assert result == [] + + +class TestDailyMixReturnsEmptyWhenAllGenresBelowMinArtists: + @pytest.mark.asyncio + async def test_returns_empty_when_all_genres_below_min_artists(self) -> None: + top_genres = [("rock", 5), ("pop", 3)] + # Each genre has only 2 artists (below the 3 minimum) + artists_by_genre = {"rock": ["a1", "a2"], "pop": ["a3", "a4"]} + genre_index = _make_genre_index( + top_genres=top_genres, artists_by_genre=artists_by_genre, + ) + cache = _make_cache() + service = _make_service(genre_index=genre_index, cache=cache) + result = await service._build_daily_mix_sections("listenbrainz", set()) + assert result == [] + + +class TestDailyMixCorrectCountWith5QualifyingGenres: + @pytest.mark.asyncio + @patch("services.discover.homepage_service.build_similar_artist_pools") + async def test_returns_correct_count_with_5_qualifying_genres( + self, mock_pools: AsyncMock, + ) -> None: + top_genres = _make_top_genres(5) + genre_names = [g for g, _ in top_genres] + artists_by_genre = _make_artists_by_genre(genre_names, artists_per=5) + albums = [ + {"title": f"Lib Album {i}", "mbid": f"lib-mbid-{i}", "artist_name": f"Artist {i}"} + for i in range(5) + ] + genre_index = _make_genre_index( + top_genres=top_genres, + artists_by_genre=artists_by_genre, + albums_by_genre=albums, + ) + cache = _make_cache() + # Return some pool items for each seed + mock_pools.return_value = [_make_pool_items(3, f"pool-{i}") for i in range(3)] + + service = _make_service(genre_index=genre_index, cache=cache) + result = await service._build_daily_mix_sections("listenbrainz", set()) + assert len(result) == 5 + + +class TestDailyMixCorrectCountWith2QualifyingGenres: + @pytest.mark.asyncio + @patch("services.discover.homepage_service.build_similar_artist_pools") + async def test_returns_correct_count_with_2_qualifying_genres( + self, mock_pools: AsyncMock, + ) -> None: + top_genres = _make_top_genres(2) + genre_names = [g for g, _ in top_genres] + artists_by_genre = _make_artists_by_genre(genre_names, artists_per=4) + albums = [{"title": "Lib 1", "mbid": "lib-1", "artist_name": "Art 1"}] + genre_index = _make_genre_index( + top_genres=top_genres, + artists_by_genre=artists_by_genre, + albums_by_genre=albums, + ) + cache = _make_cache() + mock_pools.return_value = [_make_pool_items(3)] + + service = _make_service(genre_index=genre_index, cache=cache) + result = await service._build_daily_mix_sections("listenbrainz", set()) + assert len(result) == 2 + + +class TestDailyMixSectionTypeIsAlbums: + @pytest.mark.asyncio + @patch("services.discover.homepage_service.build_similar_artist_pools") + async def test_section_type_is_albums(self, mock_pools: AsyncMock) -> None: + top_genres = [("rock", 10)] + artists_by_genre = {"rock": ["a1", "a2", "a3", "a4"]} + albums = [{"title": "Album", "mbid": "m1", "artist_name": "Art"}] + genre_index = _make_genre_index( + top_genres=top_genres, + artists_by_genre=artists_by_genre, + albums_by_genre=albums, + ) + cache = _make_cache() + mock_pools.return_value = [_make_pool_items(3)] + + service = _make_service(genre_index=genre_index, cache=cache) + result = await service._build_daily_mix_sections("listenbrainz", set()) + assert len(result) >= 1 + for section in result: + assert section.type == "albums" + + +class TestDailyMixSectionTitleFormat: + @pytest.mark.asyncio + @patch("services.discover.homepage_service.build_similar_artist_pools") + async def test_section_title_format(self, mock_pools: AsyncMock) -> None: + top_genres = [("rock", 10), ("jazz", 8)] + artists_by_genre = _make_artists_by_genre(["rock", "jazz"], artists_per=4) + albums = [{"title": "Album", "mbid": "m1", "artist_name": "Art"}] + genre_index = _make_genre_index( + top_genres=top_genres, + artists_by_genre=artists_by_genre, + albums_by_genre=albums, + ) + cache = _make_cache() + mock_pools.return_value = [_make_pool_items(3)] + + service = _make_service(genre_index=genre_index, cache=cache) + result = await service._build_daily_mix_sections("listenbrainz", set()) + assert len(result) >= 1 + for i, section in enumerate(result): + assert section.title.startswith(f"Daily Mix {i + 1} - ") + + +class TestDailyMixSectionSourceMatchesResolvedSource: + @pytest.mark.asyncio + @patch("services.discover.homepage_service.build_similar_artist_pools") + async def test_section_source_matches_resolved_source( + self, mock_pools: AsyncMock, + ) -> None: + top_genres = [("rock", 10)] + artists_by_genre = {"rock": ["a1", "a2", "a3"]} + albums = [{"title": "Album", "mbid": "m1", "artist_name": "Art"}] + genre_index = _make_genre_index( + top_genres=top_genres, + artists_by_genre=artists_by_genre, + albums_by_genre=albums, + ) + cache = _make_cache() + mock_pools.return_value = [_make_pool_items(3)] + + service = _make_service(genre_index=genre_index, cache=cache) + result = await service._build_daily_mix_sections("my_source", set()) + assert len(result) >= 1 + for section in result: + assert section.source == "my_source" + + +class TestDailyMixFamiliarVsNewRatio: + @pytest.mark.asyncio + @patch("services.discover.homepage_service.build_similar_artist_pools") + async def test_familiar_vs_new_ratio(self, mock_pools: AsyncMock) -> None: + top_genres = [("rock", 10)] + artists_by_genre = {"rock": ["a1", "a2", "a3", "a4"]} + # Provide plenty of familiar albums + albums = [ + {"title": f"Lib {i}", "mbid": f"lib-{i}", "artist_name": f"Art {i}"} + for i in range(20) + ] + genre_index = _make_genre_index( + top_genres=top_genres, + artists_by_genre=artists_by_genre, + albums_by_genre=albums, + ) + cache = _make_cache() + # Provide plenty of new items + mock_pools.return_value = [_make_pool_items(15)] + + service = _make_service(genre_index=genre_index, cache=cache) + result = await service._build_daily_mix_sections("listenbrainz", set()) + assert len(result) >= 1 + section = result[0] + total = len(section.items) + assert total > 0 + new_count = sum(1 for item in section.items if not getattr(item, "in_library", False)) + familiar_count = sum(1 for item in section.items if getattr(item, "in_library", False)) + # At least 50% new items (allowing rounding tolerance) + assert new_count >= total * 0.5 - 1 + + +class TestDailyMixCachedResultReturnedOnSecondCall: + @pytest.mark.asyncio + async def test_cached_result_returned_on_second_call(self) -> None: + cached_sections = [ + HomeSection(title="Cached Mix 1", type="albums", items=[], source="listenbrainz"), + ] + cache = _make_cache() + cache.get = AsyncMock(return_value=cached_sections) + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + service = _make_service(genre_index=genre_index, cache=cache) + + result = await service._build_daily_mix_sections("listenbrainz", set()) + assert result == cached_sections + # genre_index should not have been queried + genre_index.get_top_genres.assert_not_awaited() + + +class TestDailyMixEmptyResultIsCached: + @pytest.mark.asyncio + async def test_empty_result_is_cached(self) -> None: + genre_index = _make_genre_index(top_genres=[]) + cache = _make_cache() + service = _make_service(genre_index=genre_index, cache=cache) + + result = await service._build_daily_mix_sections("listenbrainz", set()) + assert result == [] + # Verify empty list was written to cache + cache.set.assert_awaited_once() + args = cache.set.call_args + assert args[0][1] == [] # cached value is empty list + assert args[0][2] == DAILY_MIX_CACHE_TTL + + +class TestDailyMixCacheKeyIncludesSourceAndDate: + def test_cache_key_includes_source_and_date(self) -> None: + service = _make_service() + key = service._daily_mix_cache_key("listenbrainz") + assert key.startswith("daily_mix:listenbrainz:") + # Date portion should be YYYY-MM-DD format + date_part = key.split(":")[-1] + parts = date_part.split("-") + assert len(parts) == 3 + assert len(parts[0]) == 4 # year + + +class TestDailyMixExceptionReturnsEmptyList: + @pytest.mark.asyncio + async def test_exception_in_builder_returns_empty_list(self) -> None: + genre_index = _make_genre_index() + genre_index.get_top_genres = AsyncMock(side_effect=RuntimeError("db failure")) + cache = _make_cache() + service = _make_service(genre_index=genre_index, cache=cache) + + result = await service._build_daily_mix_sections("listenbrainz", set()) + assert result == [] + + +class TestHasMeaningfulContentWithDailyMixes: + def test_has_meaningful_content_with_daily_mixes(self) -> None: + service = _make_service() + section = HomeSection(title="Mix", type="albums", items=[], source="lb") + response = DiscoverResponse(daily_mixes=[section]) + assert service._has_meaningful_content(response) is True + + def test_has_meaningful_content_empty(self) -> None: + service = _make_service() + response = DiscoverResponse() + assert service._has_meaningful_content(response) is False diff --git a/backend/tests/services/test_discover_picks.py b/backend/tests/services/test_discover_picks.py new file mode 100644 index 0000000..d202822 --- /dev/null +++ b/backend/tests/services/test_discover_picks.py @@ -0,0 +1,716 @@ +"""Tests for the Discover Picks section builder in DiscoverHomepageService.""" + +import random + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from api.v1.schemas.discover import DiscoverResponse +from api.v1.schemas.home import HomeSection, HomeAlbum +from api.v1.schemas.settings import ( + ListenBrainzConnectionSettings, + LastFmConnectionSettings, + PrimaryMusicSourceSettings, +) +from repositories.listenbrainz_models import ListenBrainzReleaseGroup +from repositories.lastfm_models import LastFmArtist +from services.discover.homepage_service import ( + DiscoverHomepageService, + DISCOVER_PICKS_CACHE_TTL, +) +from services.discover.integration_helpers import IntegrationHelpers + + +def _make_lb_settings( + enabled: bool = True, username: str = "lbuser", +) -> ListenBrainzConnectionSettings: + return ListenBrainzConnectionSettings( + user_token="tok", username=username, enabled=enabled, + ) + + +def _make_lfm_settings( + enabled: bool = True, username: str = "lfmuser", +) -> LastFmConnectionSettings: + return LastFmConnectionSettings( + api_key="key", shared_secret="secret", session_key="sk", + username=username, enabled=enabled, + ) + + +def _make_prefs( + lb_enabled: bool = True, + lfm_enabled: bool = False, + primary_source: str = "listenbrainz", + affinity_weight: float = 0.7, + picks_count: int = 12, +) -> MagicMock: + prefs = MagicMock() + prefs.get_listenbrainz_connection.return_value = _make_lb_settings(enabled=lb_enabled) + prefs.get_lastfm_connection.return_value = _make_lfm_settings(enabled=lfm_enabled) + prefs.is_lastfm_enabled.return_value = lfm_enabled + prefs.get_primary_music_source.return_value = PrimaryMusicSourceSettings(source=primary_source) + + jf_settings = MagicMock() + jf_settings.enabled = False + jf_settings.jellyfin_url = "" + jf_settings.api_key = "" + prefs.get_jellyfin_connection.return_value = jf_settings + + lidarr = MagicMock() + lidarr.lidarr_url = "" + lidarr.lidarr_api_key = "" + prefs.get_lidarr_connection.return_value = lidarr + + yt = MagicMock() + yt.enabled = False + yt.api_key = "" + prefs.get_youtube_connection.return_value = yt + + lf = MagicMock() + lf.enabled = False + lf.music_path = "" + prefs.get_local_files_connection.return_value = lf + + adv = MagicMock() + adv.discover_queue_size = 10 + adv.discover_queue_ttl = 3600 + adv.discover_queue_seed_artists = 3 + adv.discover_queue_wildcard_slots = 2 + adv.discover_queue_similar_artists_limit = 15 + adv.discover_queue_albums_per_similar = 3 + adv.discover_queue_enrich_ttl = 3600 + adv.discover_queue_lastfm_mbid_max_lookups = 10 + adv.discover_picks_genre_affinity_weight = affinity_weight + adv.discover_picks_count = picks_count + prefs.get_advanced_settings.return_value = adv + + return prefs + + +def _make_genre_index( + top_genres: list[tuple[str, int]] | None = None, + genres_for_artists: dict[str, list[str]] | None = None, +) -> AsyncMock: + genre_index = AsyncMock() + genre_index.get_top_genres = AsyncMock(return_value=top_genres or []) + genre_index.get_genres_for_artists = AsyncMock(return_value=genres_for_artists or {}) + return genre_index + + +def _make_cache() -> AsyncMock: + cache = AsyncMock() + cache.get = AsyncMock(return_value=None) + cache.set = AsyncMock() + return cache + + +def _make_release_groups( + count: int, + prefix: str = "rg", + artist_genres: dict[str, list[str]] | None = None, +) -> list[ListenBrainzReleaseGroup]: + return [ + ListenBrainzReleaseGroup( + release_group_name=f"Album {prefix}-{i}", + artist_name=f"Artist {prefix}-{i}", + listen_count=100 - i, + release_group_mbid=f"{prefix}-mbid-{i}", + artist_mbids=[f"artist-{prefix}-{i}"], + ) + for i in range(count) + ] + + +def _make_service( + genre_index: AsyncMock | None = None, + cache: AsyncMock | None = None, + prefs: MagicMock | None = None, + lastfm_repo: AsyncMock | None = None, + mbid_store: AsyncMock | None = None, +) -> DiscoverHomepageService: + lb_repo = AsyncMock() + jf_repo = AsyncMock() + lidarr_repo = AsyncMock() + mb_repo = AsyncMock() + if prefs is None: + prefs = _make_prefs() + integration = IntegrationHelpers(prefs) + mbid_resolution = MagicMock() + + return DiscoverHomepageService( + listenbrainz_repo=lb_repo, + jellyfin_repo=jf_repo, + lidarr_repo=lidarr_repo, + musicbrainz_repo=mb_repo, + integration=integration, + mbid_resolution=mbid_resolution, + memory_cache=cache, + genre_index=genre_index, + lastfm_repo=lastfm_repo, + mbid_store=mbid_store, + ) + + +# --------------------------------------------------------------------------- +# Test 16 - constant value +# --------------------------------------------------------------------------- +class TestConstantValue: + def test_discover_picks_cache_ttl_is_14400(self) -> None: + assert DISCOVER_PICKS_CACHE_TTL == 14400 + + +# --------------------------------------------------------------------------- +# Test 15 - cache key format +# --------------------------------------------------------------------------- +class TestCacheKeyFormat: + def test_cache_key_format_listenbrainz(self) -> None: + service = _make_service() + assert service._discover_picks_cache_key("listenbrainz") == "discover_picks:listenbrainz" + + def test_cache_key_format_lastfm(self) -> None: + service = _make_service() + assert service._discover_picks_cache_key("lastfm") == "discover_picks:lastfm" + + +# --------------------------------------------------------------------------- +# Test 1 - returns None when genre_index is None +# --------------------------------------------------------------------------- +class TestReturnsNoneWhenGenreIndexIsNone: + @pytest.mark.asyncio + async def test_returns_none_when_genre_index_is_none(self) -> None: + service = _make_service(genre_index=None) + result = await service._build_discover_picks(set(), "listenbrainz", True, "user") + assert result is None + + +# --------------------------------------------------------------------------- +# Test 2 - returns None when candidate pool is empty +# --------------------------------------------------------------------------- +class TestReturnsNoneWhenCandidatePoolIsEmpty: + @pytest.mark.asyncio + async def test_returns_none_when_candidate_pool_is_empty(self) -> None: + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + service = _make_service(genre_index=genre_index, cache=cache) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=[]) + + result = await service._build_discover_picks(set(), "listenbrainz", True, "user") + assert result is None + + +# --------------------------------------------------------------------------- +# Test 3 - returns None when all candidates in library +# --------------------------------------------------------------------------- +class TestReturnsNoneWhenAllCandidatesInLibrary: + @pytest.mark.asyncio + async def test_returns_none_when_all_candidates_in_library(self) -> None: + candidates = _make_release_groups(5) + library_mbids = {c.release_group_mbid.lower() for c in candidates} + + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + service = _make_service(genre_index=genre_index, cache=cache) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates) + + result = await service._build_discover_picks(library_mbids, "listenbrainz", True, "user") + assert result is None + + +# --------------------------------------------------------------------------- +# Test 4 - returns section with correct count +# --------------------------------------------------------------------------- +class TestReturnsSectionWithCorrectCount: + @pytest.mark.asyncio + async def test_returns_section_with_correct_count(self) -> None: + candidates = _make_release_groups(50) + genre_index = _make_genre_index( + top_genres=[("rock", 50), ("electronic", 30)], + genres_for_artists={}, + ) + cache = _make_cache() + prefs = _make_prefs(picks_count=12) + service = _make_service(genre_index=genre_index, cache=cache, prefs=prefs) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates) + + result = await service._build_discover_picks(set(), "listenbrainz", True, "user") + assert result is not None + assert len(result.items) == 12 + + +# --------------------------------------------------------------------------- +# Test 5 - returns section with fewer when pool smaller than count +# --------------------------------------------------------------------------- +class TestReturnsSectionWithFewerWhenPoolSmaller: + @pytest.mark.asyncio + async def test_returns_section_with_fewer_when_pool_smaller_than_count(self) -> None: + candidates = _make_release_groups(5) + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + prefs = _make_prefs(picks_count=12) + service = _make_service(genre_index=genre_index, cache=cache, prefs=prefs) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates) + + result = await service._build_discover_picks(set(), "listenbrainz", True, "user") + assert result is not None + assert len(result.items) == 5 + + +# --------------------------------------------------------------------------- +# Test 6 - section type is albums +# --------------------------------------------------------------------------- +class TestSectionTypeIsAlbums: + @pytest.mark.asyncio + async def test_section_type_is_albums(self) -> None: + candidates = _make_release_groups(5) + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + service = _make_service(genre_index=genre_index, cache=cache) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates) + + result = await service._build_discover_picks(set(), "listenbrainz", True, "user") + assert result is not None + assert result.type == "albums" + + +# --------------------------------------------------------------------------- +# Test 7 - section title is Discover Picks +# --------------------------------------------------------------------------- +class TestSectionTitleIsDiscoverPicks: + @pytest.mark.asyncio + async def test_section_title_is_discover_picks(self) -> None: + candidates = _make_release_groups(5) + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + service = _make_service(genre_index=genre_index, cache=cache) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates) + + result = await service._build_discover_picks(set(), "listenbrainz", True, "user") + assert result is not None + assert result.title == "Discover Picks" + + +# --------------------------------------------------------------------------- +# Test 8 - section source matches resolved_source +# --------------------------------------------------------------------------- +class TestSectionSourceMatchesResolvedSource: + @pytest.mark.asyncio + async def test_section_source_matches_resolved_source(self) -> None: + candidates = _make_release_groups(5) + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + service = _make_service(genre_index=genre_index, cache=cache) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates) + + result = await service._build_discover_picks(set(), "my_source", True, "user") + assert result is not None + assert result.source == "my_source" + + +# --------------------------------------------------------------------------- +# Test 9 - library mbids excluded +# --------------------------------------------------------------------------- +class TestLibraryMbidsExcluded: + @pytest.mark.asyncio + async def test_library_mbids_excluded(self) -> None: + candidates = _make_release_groups(10) + # Put the first 3 candidates in library + library_mbids = {candidates[i].release_group_mbid.lower() for i in range(3)} + + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + prefs = _make_prefs(picks_count=20) + service = _make_service(genre_index=genre_index, cache=cache, prefs=prefs) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates) + + result = await service._build_discover_picks(library_mbids, "listenbrainz", True, "user") + assert result is not None + assert len(result.items) == 7 + result_mbids = {item.mbid.lower() for item in result.items if item.mbid} + assert not result_mbids & library_mbids + + +# --------------------------------------------------------------------------- +# Test 10 - genre affinity weight 1.0 biases by genre +# --------------------------------------------------------------------------- +class TestGenreAffinityWeight1BiasesByGenre: + @pytest.mark.asyncio + async def test_genre_affinity_weight_1_biases_by_genre(self) -> None: + # 6 candidates with matching genres, 6 without + matching = _make_release_groups(6, prefix="match") + non_matching = _make_release_groups(6, prefix="nomatch") + all_candidates = matching + non_matching + + genres_for_artists = {} + for c in matching: + genres_for_artists[c.artist_mbids[0].lower()] = ["rock", "indie"] + for c in non_matching: + genres_for_artists[c.artist_mbids[0].lower()] = ["country", "folk"] + + genre_index = _make_genre_index( + top_genres=[("rock", 50), ("indie", 30)], + genres_for_artists=genres_for_artists, + ) + cache = _make_cache() + prefs = _make_prefs(affinity_weight=1.0, picks_count=6) + service = _make_service(genre_index=genre_index, cache=cache, prefs=prefs) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=all_candidates) + + result = await service._build_discover_picks(set(), "listenbrainz", True, "user") + assert result is not None + assert len(result.items) == 6 + # All top-6 should be the matching-genre candidates + result_mbids = {item.mbid for item in result.items} + match_mbids = {c.release_group_mbid for c in matching} + assert result_mbids == match_mbids + + +# --------------------------------------------------------------------------- +# Test 11 - genre affinity weight 0.0 is fully random +# --------------------------------------------------------------------------- +class TestGenreAffinityWeight0IsFullyRandom: + @pytest.mark.asyncio + async def test_genre_affinity_weight_0_is_fully_random(self) -> None: + candidates = _make_release_groups(20) + genres_for_artists = { + c.artist_mbids[0].lower(): ["rock"] for c in candidates[:10] + } + genre_index = _make_genre_index( + top_genres=[("rock", 50)], + genres_for_artists=genres_for_artists, + ) + cache = _make_cache() + prefs = _make_prefs(affinity_weight=0.0, picks_count=10) + service = _make_service(genre_index=genre_index, cache=cache, prefs=prefs) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates) + + random.seed(42) + result = await service._build_discover_picks(set(), "listenbrainz", True, "user") + assert result is not None + assert len(result.items) == 10 + # With weight=0.0, genre overlap doesn't matter - order is random. + # Run again with same seed to verify determinism. + cache_second = _make_cache() + service2 = _make_service(genre_index=genre_index, cache=cache_second, prefs=prefs) + service2._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates) + # Reset genre_index mocks since they were consumed + genre_index.get_top_genres = AsyncMock(return_value=[("rock", 50)]) + genre_index.get_genres_for_artists = AsyncMock(return_value=genres_for_artists) + + random.seed(42) + result2 = await service2._build_discover_picks(set(), "listenbrainz", True, "user") + assert result2 is not None + assert [item.mbid for item in result.items] == [item.mbid for item in result2.items] + + +# --------------------------------------------------------------------------- +# Test 12 - settings override changes count +# --------------------------------------------------------------------------- +class TestSettingsOverrideChangesCount: + @pytest.mark.asyncio + async def test_settings_override_changes_count(self) -> None: + candidates = _make_release_groups(20) + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + prefs = _make_prefs(affinity_weight=0.5, picks_count=8) + service = _make_service(genre_index=genre_index, cache=cache, prefs=prefs) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates) + + result = await service._build_discover_picks(set(), "listenbrainz", True, "user") + assert result is not None + assert len(result.items) == 8 + + +# --------------------------------------------------------------------------- +# Test 13 - cached result returned on second call +# --------------------------------------------------------------------------- +class TestCachedResultReturnedOnSecondCall: + @pytest.mark.asyncio + async def test_cached_result_returned_on_second_call(self) -> None: + candidates = _make_release_groups(10) + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + prefs = _make_prefs(picks_count=5) + service = _make_service(genre_index=genre_index, cache=cache, prefs=prefs) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates) + + # First call - should fetch from LB + result1 = await service._build_discover_picks(set(), "listenbrainz", True, "user") + assert result1 is not None + assert service._lb_repo.get_sitewide_top_release_groups.await_count == 1 + + # Simulate cache returning the wrapper on second call + cache.get = AsyncMock(return_value={"section": result1}) + result2 = await service._build_discover_picks(set(), "listenbrainz", True, "user") + assert result2 is result1 + # LB repo should NOT have been called again + assert service._lb_repo.get_sitewide_top_release_groups.await_count == 1 + + +# --------------------------------------------------------------------------- +# Test 14 - cache TTL is 4 hours +# --------------------------------------------------------------------------- +class TestCacheTtlIs4Hours: + @pytest.mark.asyncio + async def test_cache_ttl_is_4_hours(self) -> None: + candidates = _make_release_groups(5) + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + service = _make_service(genre_index=genre_index, cache=cache) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates) + + await service._build_discover_picks(set(), "listenbrainz", True, "user") + assert cache.set.await_count >= 1 + # Find the call that cached the result + for call in cache.set.call_args_list: + args = call[0] + if args[0] == "discover_picks:listenbrainz": + assert args[2] == 14400 + return + pytest.fail("cache.set was not called with discover_picks key") + + +# --------------------------------------------------------------------------- +# Test 17 - exception returns None +# --------------------------------------------------------------------------- +class TestExceptionReturnsNone: + @pytest.mark.asyncio + async def test_exception_returns_none(self) -> None: + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + service = _make_service(genre_index=genre_index, cache=cache) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock( + side_effect=Exception("API failure"), + ) + + result = await service._build_discover_picks(set(), "listenbrainz", True, "user") + assert result is None + + +# --------------------------------------------------------------------------- +# Test 18 - Last.fm path when LB disabled +# --------------------------------------------------------------------------- +class TestLastfmPathWhenLbDisabled: + @pytest.mark.asyncio + async def test_lastfm_path_when_lb_disabled(self) -> None: + lfm_repo = AsyncMock() + lfm_artists = [ + LastFmArtist(name="Artist A", mbid="lfm-artist-1"), + LastFmArtist(name="Artist B", mbid="lfm-artist-2"), + ] + lfm_repo.get_global_top_artists = AsyncMock(return_value=lfm_artists) + + lb_release_groups = _make_release_groups(3, prefix="lfm-rg") + + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + prefs = _make_prefs(lb_enabled=False, lfm_enabled=True) + service = _make_service( + genre_index=genre_index, cache=cache, prefs=prefs, lastfm_repo=lfm_repo, + ) + service._lb_repo.get_artist_top_release_groups = AsyncMock( + return_value=lb_release_groups, + ) + + result = await service._build_discover_picks(set(), "lastfm", False, None) + assert result is not None + lfm_repo.get_global_top_artists.assert_awaited_once() + assert len(result.items) > 0 + + +# --------------------------------------------------------------------------- +# Test 19 - _has_meaningful_content with discover_picks +# --------------------------------------------------------------------------- +class TestHasMeaningfulContentWithDiscoverPicks: + def test_has_meaningful_content_with_discover_picks(self) -> None: + service = _make_service() + response = DiscoverResponse( + discover_picks=HomeSection( + title="Discover Picks", + type="albums", + items=[HomeAlbum(name="A", mbid="m1", artist_name="Art")], + source="listenbrainz", + ), + ) + assert service._has_meaningful_content(response) is True + + def test_has_meaningful_content_false_when_empty(self) -> None: + service = _make_service() + response = DiscoverResponse() + assert service._has_meaningful_content(response) is False + + +# --------------------------------------------------------------------------- +# Test 20 - wired into build_discover_data +# --------------------------------------------------------------------------- +class TestWiredIntoBuildDiscoverData: + @pytest.mark.asyncio + async def test_wired_into_build_discover_data(self) -> None: + candidates = _make_release_groups(5) + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + service = _make_service(genre_index=genre_index, cache=cache) + + # Wire up minimal mocks for build_discover_data + service._lb_repo.get_sitewide_top_release_groups = AsyncMock( + return_value=candidates, + ) + service._lb_repo.get_user_top_artists = AsyncMock(return_value=[]) + service._lb_repo.get_user_fresh_releases = AsyncMock(return_value=[]) + service._lb_repo.get_user_genre_activity = AsyncMock(return_value=[]) + service._lb_repo.get_similar_artists = AsyncMock(return_value=[]) + service._lb_repo.get_sitewide_top_artists = AsyncMock(return_value=[]) + service._jf_repo.get_most_played_artists = AsyncMock(return_value=[]) + service._mbid.get_library_artist_mbids = AsyncMock(return_value=set()) + + response = await service.build_discover_data(source="listenbrainz") + assert response.discover_picks is not None + assert response.discover_picks.title == "Discover Picks" + + +# --------------------------------------------------------------------------- +# Test 21 - ignored MBIDs are filtered out +# --------------------------------------------------------------------------- +class TestIgnoredMbidsAreFilteredOut: + @pytest.mark.asyncio + async def test_ignored_mbids_are_filtered_out(self) -> None: + candidates = _make_release_groups(10) + # Mark 2 candidates as ignored + ignored_mbids = { + candidates[0].release_group_mbid.lower(), + candidates[1].release_group_mbid.lower(), + } + + mbid_store = AsyncMock() + mbid_store.get_ignored_release_mbids = AsyncMock(return_value=ignored_mbids) + + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + prefs = _make_prefs(picks_count=20) + service = _make_service( + genre_index=genre_index, cache=cache, prefs=prefs, mbid_store=mbid_store, + ) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates) + + result = await service._build_discover_picks(set(), "listenbrainz", True, "user") + assert result is not None + assert len(result.items) == 8 + result_mbids = {item.mbid.lower() for item in result.items if item.mbid} + assert not result_mbids & ignored_mbids + + +# --------------------------------------------------------------------------- +# Test 22 - Last.fm path when source is lastfm (even if LB is enabled) +# --------------------------------------------------------------------------- +class TestLastfmPathWhenSourceIsLastfm: + @pytest.mark.asyncio + async def test_lastfm_source_uses_lfm_repo_when_lb_also_enabled(self) -> None: + lfm_repo = AsyncMock() + lfm_artists = [ + LastFmArtist(name="Artist A", mbid="lfm-artist-1"), + ] + lfm_repo.get_global_top_artists = AsyncMock(return_value=lfm_artists) + + lb_release_groups = _make_release_groups(3, prefix="lfm-rg") + + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + prefs = _make_prefs(lb_enabled=True, lfm_enabled=True) + service = _make_service( + genre_index=genre_index, cache=cache, prefs=prefs, lastfm_repo=lfm_repo, + ) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=[]) + service._lb_repo.get_artist_top_release_groups = AsyncMock( + return_value=lb_release_groups, + ) + + result = await service._build_discover_picks(set(), "lastfm", True, None) + assert result is not None + lfm_repo.get_global_top_artists.assert_awaited_once() + # LB sitewide should NOT have been called since source is lastfm + service._lb_repo.get_sitewide_top_release_groups.assert_not_awaited() + + @pytest.mark.asyncio + async def test_listenbrainz_source_uses_lb_repo(self) -> None: + lfm_repo = AsyncMock() + + candidates = _make_release_groups(5) + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + prefs = _make_prefs(lb_enabled=True, lfm_enabled=True) + service = _make_service( + genre_index=genre_index, cache=cache, prefs=prefs, lastfm_repo=lfm_repo, + ) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates) + + result = await service._build_discover_picks(set(), "listenbrainz", True, "user") + assert result is not None + service._lb_repo.get_sitewide_top_release_groups.assert_awaited_once() + # LFM repo should NOT have been called since source is listenbrainz + lfm_repo.get_global_top_artists.assert_not_awaited() + + @pytest.mark.asyncio + async def test_ignored_mbid_store_failure_logs_and_continues(self) -> None: + candidates = _make_release_groups(5) + + mbid_store = AsyncMock() + mbid_store.get_ignored_release_mbids = AsyncMock(side_effect=Exception("DB down")) + + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + service = _make_service( + genre_index=genre_index, cache=cache, mbid_store=mbid_store, + ) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates) + + result = await service._build_discover_picks(set(), "listenbrainz", True, "user") + # Should still succeed - ignored filtering is best-effort + assert result is not None + assert len(result.items) == 5 + + +# --------------------------------------------------------------------------- +# Test 22 - negative result is cached and reused +# --------------------------------------------------------------------------- +class TestNegativeResultIsCachedAndReused: + @pytest.mark.asyncio + async def test_negative_result_is_cached_and_reused(self) -> None: + """When all candidates are filtered out, None is cached. Second call returns None without re-fetching.""" + candidates = _make_release_groups(3) + library_mbids = {c.release_group_mbid.lower() for c in candidates} + + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + service = _make_service(genre_index=genre_index, cache=cache) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates) + + # First call - all filtered, should cache None + result1 = await service._build_discover_picks(library_mbids, "listenbrainz", True, "user") + assert result1 is None + assert service._lb_repo.get_sitewide_top_release_groups.await_count == 1 + + # Simulate cache returning the wrapper on second call + cache.get = AsyncMock(return_value={"section": None}) + result2 = await service._build_discover_picks(library_mbids, "listenbrainz", True, "user") + assert result2 is None + # LB repo should NOT have been called again + assert service._lb_repo.get_sitewide_top_release_groups.await_count == 1 + + @pytest.mark.asyncio + async def test_empty_candidates_are_cached(self) -> None: + """When candidate pool is empty, None is cached.""" + genre_index = _make_genre_index(top_genres=[("rock", 10)]) + cache = _make_cache() + service = _make_service(genre_index=genre_index, cache=cache) + service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=[]) + + result = await service._build_discover_picks(set(), "listenbrainz", True, "user") + assert result is None + # Verify cache.set was called with the wrapper + cache.set.assert_awaited() + set_calls = [ + c for c in cache.set.call_args_list + if c[0][0] == "discover_picks:listenbrainz" + ] + assert len(set_calls) == 1 + assert set_calls[0][0][1] == {"section": None} diff --git a/backend/tests/services/test_discover_radio.py b/backend/tests/services/test_discover_radio.py new file mode 100644 index 0000000..400451d --- /dev/null +++ b/backend/tests/services/test_discover_radio.py @@ -0,0 +1,537 @@ +"""Tests for DiscoverRadioService and _build_radio_sections.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi import HTTPException + +from api.v1.schemas.discover import ( + DiscoverQueueItemLight, + DiscoverResponse, + RadioRequest, +) +from api.v1.schemas.home import HomeAlbum, HomeSection +from api.v1.schemas.discovery import ( + DiscoveryAlbum, + MoreByArtistResponse, + SimilarAlbumsResponse, +) +from api.v1.schemas.settings import ( + ListenBrainzConnectionSettings, + LastFmConnectionSettings, + PrimaryMusicSourceSettings, +) +from models.album import AlbumInfo +from repositories.listenbrainz_models import ListenBrainzArtist, ListenBrainzReleaseGroup +from services.discover.homepage_service import DiscoverHomepageService +from services.discover.integration_helpers import IntegrationHelpers +from services.discover.radio_service import DiscoverRadioService + + +# ---- helpers ---- + +def _make_lb_settings( + enabled: bool = True, username: str = "lbuser", +) -> ListenBrainzConnectionSettings: + return ListenBrainzConnectionSettings( + user_token="tok", username=username, enabled=enabled, + ) + + +def _make_lfm_settings( + enabled: bool = True, username: str = "lfmuser", +) -> LastFmConnectionSettings: + return LastFmConnectionSettings( + api_key="key", shared_secret="secret", session_key="sk", + username=username, enabled=enabled, + ) + + +def _make_prefs( + lb_enabled: bool = True, + lfm_enabled: bool = False, + primary_source: str = "listenbrainz", +) -> MagicMock: + prefs = MagicMock() + prefs.get_listenbrainz_connection.return_value = _make_lb_settings(enabled=lb_enabled) + prefs.get_lastfm_connection.return_value = _make_lfm_settings(enabled=lfm_enabled) + prefs.is_lastfm_enabled.return_value = lfm_enabled + prefs.get_primary_music_source.return_value = PrimaryMusicSourceSettings(source=primary_source) + + jf_settings = MagicMock() + jf_settings.enabled = False + jf_settings.jellyfin_url = "" + jf_settings.api_key = "" + prefs.get_jellyfin_connection.return_value = jf_settings + + lidarr = MagicMock() + lidarr.lidarr_url = "" + lidarr.lidarr_api_key = "" + prefs.get_lidarr_connection.return_value = lidarr + + yt = MagicMock() + yt.enabled = False + yt.api_key = "" + prefs.get_youtube_connection.return_value = yt + + lf = MagicMock() + lf.enabled = False + lf.music_path = "" + prefs.get_local_files_connection.return_value = lf + + adv = MagicMock() + adv.discover_queue_size = 10 + adv.discover_queue_ttl = 3600 + adv.discover_queue_seed_artists = 3 + adv.discover_queue_wildcard_slots = 2 + adv.discover_queue_similar_artists_limit = 15 + adv.discover_queue_albums_per_similar = 3 + adv.discover_queue_enrich_ttl = 3600 + adv.discover_queue_lastfm_mbid_max_lookups = 10 + prefs.get_advanced_settings.return_value = adv + + return prefs + + +def _make_pool_items(count: int, prefix: str = "album") -> list[DiscoverQueueItemLight]: + return [ + DiscoverQueueItemLight( + release_group_mbid=f"rg-{prefix}-{i}", + album_name=f"{prefix.title()} {i}", + artist_name=f"Artist {i}", + artist_mbid=f"artist-{prefix}-{i}", + recommendation_reason="Similar to seed", + ) + for i in range(count) + ] + + +def _make_radio_service( + lb_repo: AsyncMock | None = None, + mb_repo: AsyncMock | None = None, + mbid_svc: MagicMock | None = None, + album_discovery: AsyncMock | None = None, + genre_index: AsyncMock | None = None, + integration: IntegrationHelpers | None = None, +) -> DiscoverRadioService: + if lb_repo is None: + lb_repo = AsyncMock() + if mb_repo is None: + mb_repo = AsyncMock() + if mbid_svc is None: + mbid_svc = MagicMock() + mbid_svc.get_library_artist_mbids = AsyncMock(return_value=set()) + mbid_svc.normalize_mbid = MagicMock(side_effect=lambda x: x.strip().lower() if x and x.strip() else None) + mbid_svc.make_queue_item = MagicMock(side_effect=lambda **kw: DiscoverQueueItemLight( + release_group_mbid=kw["release_group_mbid"], + album_name=kw["album_name"], + artist_name=kw["artist_name"], + artist_mbid=kw["artist_mbid"], + recommendation_reason=kw.get("reason", ""), + )) + if album_discovery is None: + album_discovery = AsyncMock() + if genre_index is None: + genre_index = AsyncMock() + if integration is None: + prefs = _make_prefs() + integration = IntegrationHelpers(prefs) + + return DiscoverRadioService( + lb_repo=lb_repo, + mb_repo=mb_repo, + mbid_svc=mbid_svc, + album_discovery=album_discovery, + genre_index=genre_index, + integration=integration, + ) + + +def _make_homepage_service( + genre_index: AsyncMock | None = None, + cache: AsyncMock | None = None, +) -> DiscoverHomepageService: + lb_repo = AsyncMock() + jf_repo = AsyncMock() + lidarr_repo = AsyncMock() + mb_repo = AsyncMock() + prefs = _make_prefs() + integration = IntegrationHelpers(prefs) + mbid_resolution = MagicMock() + + return DiscoverHomepageService( + listenbrainz_repo=lb_repo, + jellyfin_repo=jf_repo, + lidarr_repo=lidarr_repo, + musicbrainz_repo=mb_repo, + integration=integration, + mbid_resolution=mbid_resolution, + memory_cache=cache, + genre_index=genre_index, + ) + + +# ==== DiscoverRadioService tests ==== + + +class TestArtistSeed: + @pytest.mark.asyncio + @patch("services.discover.radio_service.round_robin_dedup_select") + @patch("services.discover.radio_service.build_similar_artist_pools") + async def test_artist_seed_returns_album_section( + self, mock_pools, mock_select, + ) -> None: + items = _make_pool_items(3) + mock_pools.return_value = [items] + mock_select.return_value = items + + lb_repo = AsyncMock() + lb_repo.get_artist_top_release_groups.return_value = [ + ListenBrainzReleaseGroup( + release_group_mbid="rg-1", + release_group_name="Album 1", + artist_name="Test Artist", + artist_mbids=["artist-1"], + caa_id=None, + caa_release_mbid=None, + listen_count=10, + ) + ] + service = _make_radio_service(lb_repo=lb_repo) + request = RadioRequest(seed_type="artist", seed_id="valid-mbid") + + result = await service.generate_radio(request) + + assert result.type == "albums" + assert result.title.startswith("Radio: ") + assert len(result.items) == 3 + assert all(isinstance(item, HomeAlbum) for item in result.items) + + @pytest.mark.asyncio + async def test_artist_seed_unknown_mbid_raises_404(self) -> None: + mbid_svc = MagicMock() + mbid_svc.get_library_artist_mbids = AsyncMock(return_value=set()) + mbid_svc.normalize_mbid = MagicMock(return_value=None) + + service = _make_radio_service(mbid_svc=mbid_svc) + request = RadioRequest(seed_type="artist", seed_id="bad-mbid") + + with pytest.raises(HTTPException) as exc_info: + await service.generate_radio(request) + assert exc_info.value.status_code == 404 + + @pytest.mark.asyncio + @patch("services.discover.radio_service.round_robin_dedup_select") + @patch("services.discover.radio_service.build_similar_artist_pools") + async def test_artist_seed_no_similar_returns_empty_section( + self, mock_pools, mock_select, + ) -> None: + mock_pools.return_value = [[]] + mock_select.return_value = [] + + lb_repo = AsyncMock() + lb_repo.get_artist_top_release_groups.return_value = [] + service = _make_radio_service(lb_repo=lb_repo) + request = RadioRequest(seed_type="artist", seed_id="valid-mbid") + + result = await service.generate_radio(request) + + assert result.type == "albums" + assert result.items == [] + + +class TestAlbumSeed: + @pytest.mark.asyncio + async def test_album_seed_returns_merged_albums(self) -> None: + mb_repo = AsyncMock() + mb_repo.get_release_group.return_value = AlbumInfo( + title="Seed Album", + musicbrainz_id="seed-album-mbid", + artist_name="Test Artist", + artist_id="artist-123", + ) + + album_discovery = AsyncMock() + album_discovery.get_similar_albums.return_value = SimilarAlbumsResponse( + albums=[ + DiscoveryAlbum(musicbrainz_id="sim-1", title="Sim 1", artist_name="A1", artist_id="a1"), + DiscoveryAlbum(musicbrainz_id="sim-2", title="Sim 2", artist_name="A2", artist_id="a2"), + ] + ) + album_discovery.get_more_by_artist.return_value = MoreByArtistResponse( + albums=[ + DiscoveryAlbum(musicbrainz_id="more-1", title="More 1", artist_name="A1", artist_id="a1"), + DiscoveryAlbum(musicbrainz_id="sim-1", title="Sim 1 dup", artist_name="A1", artist_id="a1"), + ], + artist_name="Test Artist", + ) + + service = _make_radio_service(mb_repo=mb_repo, album_discovery=album_discovery) + request = RadioRequest(seed_type="album", seed_id="seed-album-mbid") + + result = await service.generate_radio(request) + + assert result.type == "albums" + assert result.title == "Radio: Seed Album" + mbids = [item.mbid for item in result.items if isinstance(item, HomeAlbum)] + assert len(mbids) == 3 + assert len(set(mbids)) == 3 # all unique + + @pytest.mark.asyncio + async def test_album_seed_unknown_mbid_raises_404(self) -> None: + mbid_svc = MagicMock() + mbid_svc.get_library_artist_mbids = AsyncMock(return_value=set()) + mbid_svc.normalize_mbid = MagicMock(return_value=None) + + service = _make_radio_service(mbid_svc=mbid_svc) + request = RadioRequest(seed_type="album", seed_id="bad-mbid") + + with pytest.raises(HTTPException) as exc_info: + await service.generate_radio(request) + assert exc_info.value.status_code == 404 + + @pytest.mark.asyncio + async def test_album_seed_excludes_seed_album(self) -> None: + mb_repo = AsyncMock() + mb_repo.get_release_group.return_value = AlbumInfo( + title="Seed Album", + musicbrainz_id="seed-mbid", + artist_name="A", + artist_id="a-1", + ) + + album_discovery = AsyncMock() + album_discovery.get_similar_albums.return_value = SimilarAlbumsResponse( + albums=[ + DiscoveryAlbum(musicbrainz_id="seed-mbid", title="Should be excluded", artist_name="A", artist_id="a-1"), + DiscoveryAlbum(musicbrainz_id="other-1", title="Other 1", artist_name="A", artist_id="a-1"), + ] + ) + album_discovery.get_more_by_artist.return_value = MoreByArtistResponse(albums=[], artist_name="A") + + service = _make_radio_service(mb_repo=mb_repo, album_discovery=album_discovery) + request = RadioRequest(seed_type="album", seed_id="seed-mbid") + + result = await service.generate_radio(request) + + mbids = [item.mbid for item in result.items if isinstance(item, HomeAlbum)] + assert "seed-mbid" not in mbids + assert len(mbids) == 1 + + +class TestGenreSeed: + @pytest.mark.asyncio + @patch("services.discover.radio_service.round_robin_dedup_select") + @patch("services.discover.radio_service.build_similar_artist_pools") + async def test_genre_seed_returns_album_section( + self, mock_pools, mock_select, + ) -> None: + items = _make_pool_items(5) + mock_pools.return_value = [items] + mock_select.return_value = items + + genre_index = AsyncMock() + genre_index.get_artists_for_genres.return_value = { + "indie rock": ["mbid-1", "mbid-2", "mbid-3"] + } + + service = _make_radio_service(genre_index=genre_index) + request = RadioRequest(seed_type="genre", seed_id="indie rock") + + result = await service.generate_radio(request) + + assert result.type == "albums" + assert result.title == "Radio: Indie Rock" + assert len(result.items) == 5 + + @pytest.mark.asyncio + async def test_genre_seed_unknown_tag_raises_422(self) -> None: + genre_index = AsyncMock() + genre_index.get_artists_for_genres.return_value = {} + + service = _make_radio_service(genre_index=genre_index) + request = RadioRequest(seed_type="genre", seed_id="nonexistent genre") + + with pytest.raises(HTTPException) as exc_info: + await service.generate_radio(request) + assert exc_info.value.status_code == 422 + + @pytest.mark.asyncio + @patch("services.discover.radio_service.build_similar_artist_pools") + async def test_genre_seed_samples_up_to_5_artists( + self, mock_pools, + ) -> None: + mock_pools.return_value = [[] for _ in range(5)] + + genre_index = AsyncMock() + genre_index.get_artists_for_genres.return_value = { + "rock": [f"mbid-{i}" for i in range(10)] + } + + service = _make_radio_service(genre_index=genre_index) + request = RadioRequest(seed_type="genre", seed_id="rock") + + await service.generate_radio(request) + + call_args = mock_pools.call_args + seeds_passed = call_args[0][0] + assert len(seeds_passed) == 5 + + +class TestInputValidation: + @pytest.mark.asyncio + async def test_empty_seed_id_raises_400(self) -> None: + service = _make_radio_service() + request = RadioRequest(seed_type="artist", seed_id="") + + with pytest.raises(HTTPException) as exc_info: + await service.generate_radio(request) + assert exc_info.value.status_code == 400 + + @pytest.mark.asyncio + async def test_whitespace_seed_id_raises_400(self) -> None: + service = _make_radio_service() + request = RadioRequest(seed_type="artist", seed_id=" ") + + with pytest.raises(HTTPException) as exc_info: + await service.generate_radio(request) + assert exc_info.value.status_code == 400 + + +class TestCountAndSource: + @pytest.mark.asyncio + @patch("services.discover.radio_service.round_robin_dedup_select") + @patch("services.discover.radio_service.build_similar_artist_pools") + async def test_count_parameter_respected( + self, mock_pools, mock_select, + ) -> None: + items = _make_pool_items(10) + mock_pools.return_value = [items] + mock_select.return_value = items[:5] + + lb_repo = AsyncMock() + lb_repo.get_artist_top_release_groups.return_value = [] + service = _make_radio_service(lb_repo=lb_repo) + request = RadioRequest(seed_type="artist", seed_id="valid-mbid", count=5) + + result = await service.generate_radio(request) + + assert len(result.items) <= 5 + mock_select.assert_called_once_with(mock_pools.return_value, 5) + + @pytest.mark.asyncio + @patch("services.discover.radio_service.round_robin_dedup_select") + @patch("services.discover.radio_service.build_similar_artist_pools") + async def test_source_resolution( + self, mock_pools, mock_select, + ) -> None: + mock_pools.return_value = [[]] + mock_select.return_value = [] + + lb_repo = AsyncMock() + lb_repo.get_artist_top_release_groups.return_value = [] + service = _make_radio_service(lb_repo=lb_repo) + request = RadioRequest(seed_type="artist", seed_id="valid-mbid", source=None) + + result = await service.generate_radio(request) + + assert result.source == "listenbrainz" + + +# ==== DiscoverHomepageService._build_radio_sections tests ==== + + +class TestBuildRadioSections: + @pytest.mark.asyncio + @patch("services.discover.homepage_service.round_robin_dedup_select") + @patch("services.discover.homepage_service.build_similar_artist_pools") + async def test_build_radio_sections_returns_sections_for_seeds( + self, mock_pools, mock_select, + ) -> None: + items = _make_pool_items(3) + mock_pools.return_value = [items] + mock_select.return_value = items + + service = _make_homepage_service() + seeds = [ + ListenBrainzArtist(artist_name=f"Seed {i}", artist_mbids=[f"mbid-{i}"], listen_count=10) + for i in range(3) + ] + + result = await service._build_radio_sections(seeds, set(), "listenbrainz") + + assert len(result) == 3 + assert all(isinstance(s, HomeSection) for s in result) + assert all(s.type == "albums" for s in result) + assert all(s.title.startswith("Radio: ") for s in result) + + @pytest.mark.asyncio + @patch("services.discover.homepage_service.round_robin_dedup_select") + @patch("services.discover.homepage_service.build_similar_artist_pools") + async def test_build_radio_sections_isolates_failures( + self, mock_pools, mock_select, + ) -> None: + items = _make_pool_items(2) + call_count = 0 + + async def side_effect(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 2: + raise RuntimeError("LB API error") + return [items] + + mock_pools.side_effect = side_effect + mock_select.return_value = items + + service = _make_homepage_service() + seeds = [ + ListenBrainzArtist(artist_name=f"Seed {i}", artist_mbids=[f"mbid-{i}"], listen_count=10) + for i in range(3) + ] + + result = await service._build_radio_sections(seeds, set(), "listenbrainz") + + assert len(result) == 2 + + @pytest.mark.asyncio + async def test_build_radio_sections_empty_seeds(self) -> None: + service = _make_homepage_service() + result = await service._build_radio_sections([], set(), "listenbrainz") + assert result == [] + + +class TestHasMeaningfulContentWithRadioSections: + def test_has_meaningful_content_with_radio_sections(self) -> None: + service = _make_homepage_service() + response = DiscoverResponse( + radio_sections=[ + HomeSection( + title="Radio: Test", + type="albums", + items=[HomeAlbum(name="Album", mbid="mbid-1")], + ) + ] + ) + assert service._has_meaningful_content(response) is True + + def test_has_meaningful_content_empty_response(self) -> None: + service = _make_homepage_service() + response = DiscoverResponse() + assert service._has_meaningful_content(response) is False + + +class TestGenerateRadioFallbackWhenSourceDisabled: + @pytest.mark.asyncio + async def test_generate_radio_returns_fallback_section_when_source_disabled(self) -> None: + prefs = _make_prefs(lb_enabled=False, lfm_enabled=False) + integration = IntegrationHelpers(prefs) + service = _make_radio_service(integration=integration) + request = RadioRequest(seed_type="artist", seed_id="valid-mbid", source="listenbrainz") + + result = await service.generate_radio(request) + + assert result.items == [] + assert result.fallback_message == "listenbrainz is not enabled" + assert result.source == "listenbrainz" + assert result.type == "albums" diff --git a/backend/tests/services/test_discover_service.py b/backend/tests/services/test_discover_service.py index 3517334..b3598e1 100644 --- a/backend/tests/services/test_discover_service.py +++ b/backend/tests/services/test_discover_service.py @@ -706,3 +706,49 @@ class TestShowGloballyTrendingSetting: assert response.globally_trending is None assert response.service_prompts is not None + + +class TestDailyMixesWiring: + """Integration: verify build_discover_data() populates response.daily_mixes + via post_tasks wiring (Phase 5 acceptance).""" + + @pytest.mark.asyncio + async def test_daily_mixes_populated_via_post_tasks(self, monkeypatch): + from api.v1.schemas.home import HomeSection + + service, _lb, _lfm, _prefs = _make_service( + lb_enabled=True, lfm_enabled=True, primary_source="listenbrainz" + ) + stub_sections = [ + HomeSection(title="Your Rock Mix", type="albums", items=[], source="listenbrainz"), + HomeSection(title="Your Pop Mix", type="albums", items=[], source="listenbrainz"), + ] + + async def fake_daily_mix(resolved_source, library_mbids=None): + assert resolved_source == "listenbrainz" + return stub_sections + + monkeypatch.setattr( + service._homepage, "_build_daily_mix_sections", fake_daily_mix + ) + + response = await service.build_discover_data(source="listenbrainz") + + assert response.daily_mixes == stub_sections + + @pytest.mark.asyncio + async def test_daily_mixes_empty_when_builder_returns_none(self, monkeypatch): + service, _lb, _lfm, _prefs = _make_service( + lb_enabled=True, lfm_enabled=True, primary_source="listenbrainz" + ) + + async def fake_daily_mix(resolved_source, library_mbids=None): + return None + + monkeypatch.setattr( + service._homepage, "_build_daily_mix_sections", fake_daily_mix + ) + + response = await service.build_discover_data(source="listenbrainz") + + assert response.daily_mixes == [] diff --git a/backend/tests/services/test_playlist_suggestions.py b/backend/tests/services/test_playlist_suggestions.py new file mode 100644 index 0000000..cf54bc5 --- /dev/null +++ b/backend/tests/services/test_playlist_suggestions.py @@ -0,0 +1,541 @@ +"""Tests for playlist-seeded discovery: PlaylistService.analyse_playlist_profile +and DiscoverHomepageService.build_playlist_suggestions.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi import HTTPException + +from api.v1.schemas.discover import ( + DiscoverQueueItemLight, + PlaylistProfile, + PlaylistSuggestionsRequest, + PlaylistSuggestionsResponse, +) +from api.v1.schemas.home import HomeAlbum, HomeSection +from api.v1.schemas.settings import ( + ListenBrainzConnectionSettings, + LastFmConnectionSettings, + PrimaryMusicSourceSettings, +) +from repositories.listenbrainz_models import ListenBrainzArtist +from repositories.playlist_repository import PlaylistRecord, PlaylistTrackRecord +from services.discover.facade import DiscoverService +from services.discover.homepage_service import DiscoverHomepageService +from services.discover.integration_helpers import IntegrationHelpers +from services.playlist_service import PlaylistService + + +# ---- helpers ---- + + +def _make_lb_settings( + enabled: bool = True, username: str = "lbuser", +) -> ListenBrainzConnectionSettings: + return ListenBrainzConnectionSettings( + user_token="tok", username=username, enabled=enabled, + ) + + +def _make_lfm_settings( + enabled: bool = True, username: str = "lfmuser", +) -> LastFmConnectionSettings: + return LastFmConnectionSettings( + api_key="key", shared_secret="secret", session_key="sk", + username=username, enabled=enabled, + ) + + +def _make_prefs( + lb_enabled: bool = True, + lfm_enabled: bool = False, + primary_source: str = "listenbrainz", +) -> MagicMock: + prefs = MagicMock() + prefs.get_listenbrainz_connection.return_value = _make_lb_settings(enabled=lb_enabled) + prefs.get_lastfm_connection.return_value = _make_lfm_settings(enabled=lfm_enabled) + prefs.is_lastfm_enabled.return_value = lfm_enabled + prefs.get_primary_music_source.return_value = PrimaryMusicSourceSettings(source=primary_source) + + jf_settings = MagicMock() + jf_settings.enabled = False + jf_settings.jellyfin_url = "" + jf_settings.api_key = "" + prefs.get_jellyfin_connection.return_value = jf_settings + + lidarr = MagicMock() + lidarr.lidarr_url = "" + lidarr.lidarr_api_key = "" + prefs.get_lidarr_connection.return_value = lidarr + + yt = MagicMock() + yt.enabled = False + yt.api_key = "" + prefs.get_youtube_connection.return_value = yt + + lf = MagicMock() + lf.enabled = False + lf.music_path = "" + prefs.get_local_files_connection.return_value = lf + + adv = MagicMock() + adv.discover_queue_size = 10 + adv.discover_queue_ttl = 3600 + adv.discover_queue_seed_artists = 3 + adv.discover_queue_wildcard_slots = 2 + adv.discover_queue_similar_artists_limit = 15 + adv.discover_queue_albums_per_similar = 3 + adv.discover_queue_enrich_ttl = 3600 + adv.discover_queue_lastfm_mbid_max_lookups = 10 + prefs.get_advanced_settings.return_value = adv + + return prefs + + +def _make_pool_items(count: int, prefix: str = "album") -> list[DiscoverQueueItemLight]: + return [ + DiscoverQueueItemLight( + release_group_mbid=f"rg-{prefix}-{i}", + album_name=f"{prefix.title()} {i}", + artist_name=f"Artist {i}", + artist_mbid=f"artist-{prefix}-{i}", + recommendation_reason="Similar to seed", + ) + for i in range(count) + ] + + +def _make_track_record( + artist_id: str | None = None, + track_name: str = "Track", + playlist_id: str = "pl-1", +) -> PlaylistTrackRecord: + return PlaylistTrackRecord( + id="t-1", + playlist_id=playlist_id, + position=0, + track_name=track_name, + artist_name="Artist", + album_name="Album", + album_id=None, + artist_id=artist_id, + track_source_id=None, + cover_url=None, + source_type="", + available_sources=None, + format=None, + track_number=None, + disc_number=None, + duration=None, + created_at="2024-01-01", + ) + + +def _make_playlist_record(playlist_id: str = "pl-1") -> PlaylistRecord: + return PlaylistRecord( + id=playlist_id, + name="Test Playlist", + cover_image_path=None, + created_at="2024-01-01", + updated_at="2024-01-01", + ) + + +def _make_playlist_service( + repo: AsyncMock | None = None, + genre_index: AsyncMock | None = None, +) -> PlaylistService: + if repo is None: + repo = MagicMock() + service = PlaylistService.__new__(PlaylistService) + service._repo = repo + service._genre_index = genre_index + service._cache = None + return service + + +def _make_homepage_service( + genre_index: AsyncMock | None = None, + cache: AsyncMock | None = None, + lb_enabled: bool = True, + lfm_enabled: bool = False, + primary_source: str = "listenbrainz", + lastfm_repo: AsyncMock | None = None, +) -> DiscoverHomepageService: + lb_repo = AsyncMock() + jf_repo = AsyncMock() + lidarr_repo = AsyncMock() + mb_repo = AsyncMock() + prefs = _make_prefs(lb_enabled=lb_enabled, lfm_enabled=lfm_enabled, primary_source=primary_source) + integration = IntegrationHelpers(prefs) + mbid_resolution = MagicMock() + + return DiscoverHomepageService( + listenbrainz_repo=lb_repo, + jellyfin_repo=jf_repo, + lidarr_repo=lidarr_repo, + musicbrainz_repo=mb_repo, + integration=integration, + mbid_resolution=mbid_resolution, + memory_cache=cache, + genre_index=genre_index, + lastfm_repo=lastfm_repo, + ) + + +# ==== PlaylistService.analyse_playlist_profile tests ==== + + +class TestAnalyseProfileReturnsNoneForUnknownPlaylist: + @pytest.mark.asyncio + async def test_unknown_playlist_returns_none(self) -> None: + repo = AsyncMock() + repo.get_playlist.return_value = None + service = _make_playlist_service(repo=repo) + + result = await service.analyse_playlist_profile("nonexistent") + + assert result is None + + +class TestAnalyseProfileEmptyArtistMbids: + @pytest.mark.asyncio + async def test_no_artist_ids_returns_empty_list(self) -> None: + repo = AsyncMock() + repo.get_playlist.return_value = _make_playlist_record() + tracks = [ + _make_track_record(artist_id=None), + _make_track_record(artist_id=None), + ] + repo.get_tracks.return_value = tracks + service = _make_playlist_service(repo=repo) + + result = await service.analyse_playlist_profile("pl-1") + + assert result is not None + assert result.artist_mbids == [] + assert result.track_count == 2 + + +class TestAnalyseProfileExtractsUniqueArtistMbids: + @pytest.mark.asyncio + async def test_deduplicates_artist_ids(self) -> None: + repo = AsyncMock() + repo.get_playlist.return_value = _make_playlist_record() + tracks = [ + _make_track_record(artist_id="mbid-a"), + _make_track_record(artist_id="mbid-a"), + _make_track_record(artist_id="mbid-b"), + ] + repo.get_tracks.return_value = tracks + service = _make_playlist_service(repo=repo) + + result = await service.analyse_playlist_profile("pl-1") + + assert result is not None + assert sorted(result.artist_mbids) == ["mbid-a", "mbid-b"] + + +class TestAnalyseProfileBuildsGenreDistribution: + @pytest.mark.asyncio + async def test_genre_distribution_populated(self) -> None: + repo = AsyncMock() + repo.get_playlist.return_value = _make_playlist_record() + repo.get_tracks.return_value = [ + _make_track_record(artist_id="mbid-a"), + ] + genre_index = AsyncMock() + genre_index.get_genres_for_artists.return_value = { + "mbid-a": ["rock", "alternative"], + } + service = _make_playlist_service(repo=repo, genre_index=genre_index) + + result = await service.analyse_playlist_profile("pl-1") + + assert result is not None + assert result.genre_distribution == {"mbid-a": ["rock", "alternative"]} + + +class TestAnalyseProfileNoGenreIndex: + @pytest.mark.asyncio + async def test_no_genre_index_returns_empty_distribution(self) -> None: + repo = AsyncMock() + repo.get_playlist.return_value = _make_playlist_record() + repo.get_tracks.return_value = [ + _make_track_record(artist_id="mbid-a"), + ] + service = _make_playlist_service(repo=repo, genre_index=None) + + result = await service.analyse_playlist_profile("pl-1") + + assert result is not None + assert result.genre_distribution == {} + + +# ==== DiscoverService facade tests ==== + + +class TestFacadePlaylistNotFoundRaises404: + @pytest.mark.asyncio + async def test_raises_404(self) -> None: + playlist_service = AsyncMock() + playlist_service.analyse_playlist_profile.return_value = None + homepage = AsyncMock() + + facade = DiscoverService.__new__(DiscoverService) + facade._playlist_service = playlist_service + facade._homepage = homepage + facade._integration = MagicMock() + + with pytest.raises(HTTPException) as exc_info: + await facade.get_playlist_suggestions( + PlaylistSuggestionsRequest(playlist_id="missing"), + ) + assert exc_info.value.status_code == 404 + + +class TestFacadeEmptyProfileRaises422: + @pytest.mark.asyncio + async def test_raises_422(self) -> None: + playlist_service = AsyncMock() + playlist_service.analyse_playlist_profile.return_value = PlaylistProfile( + artist_mbids=[], track_count=5, + ) + homepage = AsyncMock() + + facade = DiscoverService.__new__(DiscoverService) + facade._playlist_service = playlist_service + facade._homepage = homepage + facade._integration = MagicMock() + + with pytest.raises(HTTPException) as exc_info: + await facade.get_playlist_suggestions( + PlaylistSuggestionsRequest(playlist_id="pl-1"), + ) + assert exc_info.value.status_code == 422 + + +class TestFacadeDelegatesToHomepageBuild: + @pytest.mark.asyncio + async def test_delegates_and_returns_response(self) -> None: + profile = PlaylistProfile(artist_mbids=["mbid-a"], track_count=3) + section = HomeSection(title="Suggestions for your playlist", type="albums") + + playlist_service = AsyncMock() + playlist_service.analyse_playlist_profile.return_value = profile + homepage = AsyncMock() + homepage.build_playlist_suggestions.return_value = section + + facade = DiscoverService.__new__(DiscoverService) + facade._playlist_service = playlist_service + facade._homepage = homepage + facade._integration = MagicMock() + + result = await facade.get_playlist_suggestions( + PlaylistSuggestionsRequest(playlist_id="pl-1"), + ) + assert isinstance(result, PlaylistSuggestionsResponse) + assert result.playlist_id == "pl-1" + assert result.profile == profile + assert result.suggestions == section + homepage.build_playlist_suggestions.assert_awaited_once() + + +# ==== DiscoverHomepageService.build_playlist_suggestions tests ==== + + +class TestBuildSuggestionsReturnsAlbumSection: + @pytest.mark.asyncio + @patch("services.discover.homepage_service.round_robin_dedup_select") + @patch("services.discover.homepage_service.build_similar_artist_pools") + async def test_returns_album_section(self, mock_pools, mock_select) -> None: + items = _make_pool_items(3) + mock_pools.return_value = [items] + mock_select.return_value = items + + service = _make_homepage_service() + profile = PlaylistProfile(artist_mbids=["a-1", "a-2", "a-3"], track_count=10) + + result = await service.build_playlist_suggestions(profile) + + assert result.type == "albums" + assert result.title == "Suggestions for your playlist" + assert len(result.items) == 3 + assert all(isinstance(a, HomeAlbum) for a in result.items) + + +class TestBuildSuggestionsUsesGenrePool: + @pytest.mark.asyncio + @patch("services.discover.homepage_service.discover_by_genres") + @patch("services.discover.homepage_service.round_robin_dedup_select") + @patch("services.discover.homepage_service.build_similar_artist_pools") + async def test_calls_discover_by_genres( + self, mock_pools, mock_select, mock_genre_discover, + ) -> None: + mock_pools.return_value = [_make_pool_items(2)] + mock_genre_discover.return_value = _make_pool_items(2, prefix="genre") + mock_select.return_value = _make_pool_items(4) + + service = _make_homepage_service() + profile = PlaylistProfile( + artist_mbids=["a-1"], + genre_distribution={"a-1": ["rock", "jazz"]}, + track_count=5, + ) + + await service.build_playlist_suggestions(profile) + + mock_genre_discover.assert_awaited_once() + call_args = mock_genre_discover.call_args + assert "rock" in call_args.args[0] or "jazz" in call_args.args[0] + + +class TestBuildSuggestionsEmptyResultsReturnsFallback: + @pytest.mark.asyncio + @patch("services.discover.homepage_service.round_robin_dedup_select") + @patch("services.discover.homepage_service.build_similar_artist_pools") + async def test_empty_results_returns_fallback(self, mock_pools, mock_select) -> None: + mock_pools.return_value = [[]] + mock_select.return_value = [] + + service = _make_homepage_service() + profile = PlaylistProfile(artist_mbids=["a-1"], track_count=1) + + result = await service.build_playlist_suggestions(profile) + + assert result.items == [] + assert result.fallback_message is not None + assert "not enough suggestions" in result.fallback_message.lower() + + +class TestBuildSuggestionsCapsAtCount: + @pytest.mark.asyncio + @patch("services.discover.homepage_service.round_robin_dedup_select") + @patch("services.discover.homepage_service.build_similar_artist_pools") + async def test_caps_at_count(self, mock_pools, mock_select) -> None: + items = _make_pool_items(5) + mock_pools.return_value = [items] + mock_select.return_value = items + + service = _make_homepage_service() + profile = PlaylistProfile(artist_mbids=["a-1"], track_count=10) + + result = await service.build_playlist_suggestions(profile, count=5) + + mock_select.assert_called_once_with(mock_pools.return_value, 5) + assert len(result.items) <= 5 + + +class TestBuildSuggestionsExcludesPlaylistArtists: + @pytest.mark.asyncio + @patch("services.discover.homepage_service.round_robin_dedup_select") + @patch("services.discover.homepage_service.build_similar_artist_pools") + async def test_excluded_mbids_matches_profile(self, mock_pools, mock_select) -> None: + mock_pools.return_value = [[]] + mock_select.return_value = _make_pool_items(1) + + service = _make_homepage_service() + profile = PlaylistProfile(artist_mbids=["a-1", "a-2"], track_count=5) + + await service.build_playlist_suggestions(profile) + + call_kwargs = mock_pools.call_args + assert call_kwargs.kwargs["excluded_mbids"] == {"a-1", "a-2"} + + +class TestBuildSuggestionsSourceResolution: + @pytest.mark.asyncio + @patch("services.discover.homepage_service.round_robin_dedup_select") + @patch("services.discover.homepage_service.build_similar_artist_pools") + async def test_source_resolution(self, mock_pools, mock_select) -> None: + mock_pools.return_value = [_make_pool_items(1)] + mock_select.return_value = _make_pool_items(1) + + service = _make_homepage_service() + profile = PlaylistProfile(artist_mbids=["a-1"], track_count=5) + + result = await service.build_playlist_suggestions(profile, source=None) + + assert result.source is not None + + +class TestBuildSuggestionsDisabledSourceReturnsFallback: + @pytest.mark.asyncio + async def test_disabled_source_returns_fallback(self) -> None: + service = _make_homepage_service(lb_enabled=False, lfm_enabled=False) + profile = PlaylistProfile(artist_mbids=["a-1"], track_count=5) + + result = await service.build_playlist_suggestions( + profile, source="listenbrainz", + ) + + assert result.items == [] + assert result.fallback_message is not None + assert "isn't set up yet" in result.fallback_message.lower() + +class TestBuildSuggestionsDisabledSourceNoneReturnsFallback: + """Regression: when source=None and both providers disabled, fallback must trigger.""" + + @pytest.mark.asyncio + async def test_source_none_both_disabled_returns_fallback(self) -> None: + service = _make_homepage_service(lb_enabled=False, lfm_enabled=False) + profile = PlaylistProfile(artist_mbids=["a-1"], track_count=5) + + result = await service.build_playlist_suggestions(profile, source=None) + + assert result.items == [] + assert result.fallback_message is not None + assert "isn't set up yet" in result.fallback_message.lower() + + +class TestBuildSuggestionsLastfmPathCallsLastfmRepo: + """When source is lastfm and lfm is enabled, build_similar_artist_pools_lastfm is used.""" + + @pytest.mark.asyncio + @patch("services.discover.homepage_service.round_robin_dedup_select") + @patch("services.discover.homepage_service.build_similar_artist_pools_lastfm") + async def test_lastfm_source_uses_lastfm_pool_builder( + self, mock_lfm_pools, mock_select, + ) -> None: + items = _make_pool_items(3, prefix="lfm") + mock_lfm_pools.return_value = [items] + mock_select.return_value = items + + lfm_repo = AsyncMock() + service = _make_homepage_service( + lb_enabled=True, lfm_enabled=True, + primary_source="lastfm", lastfm_repo=lfm_repo, + ) + profile = PlaylistProfile(artist_mbids=["a-1", "a-2", "a-3"], track_count=10) + + result = await service.build_playlist_suggestions(profile, source="lastfm") + + mock_lfm_pools.assert_awaited_once() + call_kwargs = mock_lfm_pools.call_args + assert call_kwargs.kwargs["lfm_repo"] is lfm_repo + assert result.source == "lastfm" + assert len(result.items) == 3 + + @pytest.mark.asyncio + @patch("services.discover.homepage_service.round_robin_dedup_select") + @patch("services.discover.homepage_service.build_similar_artist_pools") + async def test_listenbrainz_source_uses_lb_pool_builder( + self, mock_lb_pools, mock_select, + ) -> None: + items = _make_pool_items(3, prefix="lb") + mock_lb_pools.return_value = [items] + mock_select.return_value = items + + lfm_repo = AsyncMock() + service = _make_homepage_service( + lb_enabled=True, lfm_enabled=True, + primary_source="listenbrainz", lastfm_repo=lfm_repo, + ) + profile = PlaylistProfile(artist_mbids=["a-1", "a-2", "a-3"], track_count=10) + + result = await service.build_playlist_suggestions(profile, source="listenbrainz") + + mock_lb_pools.assert_awaited_once() + call_kwargs = mock_lb_pools.call_args + assert call_kwargs.kwargs["lb_repo"] is service._lb_repo + assert result.source == "listenbrainz" diff --git a/backend/tests/services/test_queue_strategies.py b/backend/tests/services/test_queue_strategies.py new file mode 100644 index 0000000..9ba9c42 --- /dev/null +++ b/backend/tests/services/test_queue_strategies.py @@ -0,0 +1,582 @@ +"""Unit tests for queue_strategies - pure functions extracted from DiscoverQueueService.""" + +import random +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from api.v1.schemas.discover import DiscoverQueueItemLight +from repositories.listenbrainz_models import ( + ListenBrainzArtist, + ListenBrainzReleaseGroup, + ListenBrainzSimilarArtist, +) +from services.discover.queue_strategies import ( + VARIOUS_ARTISTS_MBID, + build_similar_artist_pools, + discover_by_genres, + get_artist_deep_cuts, + get_trending_filler, + interleave_at_positions, + round_robin_dedup_select, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _item(rg_mbid: str, artist_mbid: str = "artist-1") -> DiscoverQueueItemLight: + return DiscoverQueueItemLight( + release_group_mbid=rg_mbid, + album_name="Album", + artist_name="Artist", + artist_mbid=artist_mbid, + recommendation_reason="test", + cover_url="", + is_wildcard=False, + in_library=False, + ) + + +def _make_mbid_svc() -> MagicMock: + """Return a lightweight MbidResolutionService fake.""" + svc = MagicMock() + svc.normalize_mbid = lambda x: x.strip().lower() if x else None + svc.make_queue_item = lambda **kw: DiscoverQueueItemLight( + release_group_mbid=kw["release_group_mbid"], + album_name=kw["album_name"], + artist_name=kw["artist_name"], + artist_mbid=kw["artist_mbid"], + recommendation_reason=kw["reason"], + cover_url=f"/api/v1/covers/release-group/{kw['release_group_mbid']}?size=500", + is_wildcard=kw.get("is_wildcard", False), + in_library=False, + ) + return svc + + +# =================================================================== +# round_robin_dedup_select +# =================================================================== + + +class TestRoundRobinDedupSelect: + def test_respects_count_cap(self) -> None: + pools = [ + [_item(f"rg-{i}", f"a-{i}") for i in range(5)], + [_item(f"rg-{i+10}", f"a-{i+10}") for i in range(5)], + [_item(f"rg-{i+20}", f"a-{i+20}") for i in range(5)], + ] + random.seed(42) + result = round_robin_dedup_select(pools, 4) + assert len(result) == 4 + + def test_dedup_by_mbid(self) -> None: + pools = [ + [_item("dup-rg", "a-1")], + [_item("dup-rg", "a-2")], + ] + random.seed(42) + result = round_robin_dedup_select(pools, 10) + assert len(result) == 1 + + def test_max_per_artist(self) -> None: + pool = [_item(f"rg-{i}", "same-artist") for i in range(5)] + random.seed(42) + result = round_robin_dedup_select([pool], 10) + assert len(result) == 2 + + def test_empty_pools(self) -> None: + result = round_robin_dedup_select([], 5) + assert result == [] + + def test_round_robin_balance(self) -> None: + pool_a = [_item(f"a-rg-{i}", f"art-a-{i}") for i in range(3)] + pool_b = [_item(f"b-rg-{i}", f"art-b-{i}") for i in range(3)] + random.seed(42) + result = round_robin_dedup_select([pool_a, pool_b], 4) + assert len(result) == 4 + pool_a_ids = {it.release_group_mbid for it in pool_a} + pool_b_ids = {it.release_group_mbid for it in pool_b} + from_a = sum(1 for it in result if it.release_group_mbid in pool_a_ids) + from_b = sum(1 for it in result if it.release_group_mbid in pool_b_ids) + assert from_a >= 1 + assert from_b >= 1 + + +# =================================================================== +# interleave_at_positions +# =================================================================== + + +class TestInterleaveAtPositions: + def test_inserts_at_correct_indices(self) -> None: + base = [_item(f"base-{i}") for i in range(10)] + ins = [_item("ins-0", "w"), _item("ins-1", "w")] + result = interleave_at_positions(base, ins) + assert result[2].release_group_mbid == "ins-0" + assert result[7].release_group_mbid == "ins-1" + assert len(result) == 12 + + def test_empty_insertions(self) -> None: + base = [_item(f"b-{i}") for i in range(5)] + result = interleave_at_positions(base, []) + assert len(result) == 5 + + def test_more_insertions_than_positions(self) -> None: + base = [_item(f"b-{i}") for i in range(10)] + ins = [_item("i-0", "w"), _item("i-1", "w"), _item("i-2", "w")] + result = interleave_at_positions(base, ins) + assert len(result) == 13 + assert result[-1].release_group_mbid == "i-2" + + def test_short_base(self) -> None: + base = [_item("only")] + ins = [_item("i-0", "w"), _item("i-1", "w")] + result = interleave_at_positions(base, ins) + assert len(result) == 3 + + def test_custom_positions(self) -> None: + base = [_item(f"b-{i}") for i in range(10)] + ins = [_item("i-0", "w"), _item("i-1", "w")] + result = interleave_at_positions(base, ins, positions=[0, 5]) + assert result[0].release_group_mbid == "i-0" + assert result[5].release_group_mbid == "i-1" + + +# =================================================================== +# build_similar_artist_pools +# =================================================================== + + +class TestBuildSimilarArtistPools: + @pytest.mark.asyncio + async def test_returns_one_pool_per_seed(self) -> None: + lb_repo = AsyncMock() + lb_repo.get_similar_artists.return_value = [ + ListenBrainzSimilarArtist(artist_mbid="sim-1", artist_name="Sim1", listen_count=100), + ] + lb_repo.get_artist_top_release_groups.return_value = [ + ListenBrainzReleaseGroup( + release_group_name="Album1", artist_name="Sim1", + listen_count=50, release_group_mbid="rg-1", + ), + ] + mbid_svc = _make_mbid_svc() + seeds = [ + ListenBrainzArtist(artist_name="Seed1", listen_count=200, artist_mbids=["seed-1"]), + ListenBrainzArtist(artist_name="Seed2", listen_count=150, artist_mbids=["seed-2"]), + ] + pools = await build_similar_artist_pools( + seeds, set(), 5, 3, lb_repo=lb_repo, mbid_svc=mbid_svc, + ) + assert len(pools) == 2 + assert all(len(p) > 0 for p in pools) + + @pytest.mark.asyncio + async def test_skips_various_artists(self) -> None: + lb_repo = AsyncMock() + lb_repo.get_similar_artists.return_value = [ + ListenBrainzSimilarArtist( + artist_mbid=VARIOUS_ARTISTS_MBID, artist_name="VA", listen_count=100 + ), + ] + mbid_svc = _make_mbid_svc() + seeds = [ListenBrainzArtist(artist_name="S", listen_count=1, artist_mbids=["s1"])] + pools = await build_similar_artist_pools( + seeds, set(), 5, 3, lb_repo=lb_repo, mbid_svc=mbid_svc, + ) + assert pools == [[]] + + @pytest.mark.asyncio + async def test_dedup_within_pool(self) -> None: + lb_repo = AsyncMock() + lb_repo.get_similar_artists.return_value = [ + ListenBrainzSimilarArtist(artist_mbid="sim-a", artist_name="SimA", listen_count=100), + ListenBrainzSimilarArtist(artist_mbid="sim-b", artist_name="SimB", listen_count=80), + ] + lb_repo.get_artist_top_release_groups.return_value = [ + ListenBrainzReleaseGroup( + release_group_name="Same Album", artist_name="SimA", + listen_count=50, release_group_mbid="dup-rg", + ), + ] + mbid_svc = _make_mbid_svc() + seeds = [ListenBrainzArtist(artist_name="S", listen_count=1, artist_mbids=["s1"])] + pools = await build_similar_artist_pools( + seeds, set(), 5, 3, lb_repo=lb_repo, mbid_svc=mbid_svc, + ) + assert len(pools[0]) == 1 + + @pytest.mark.asyncio + async def test_excludes_excluded_mbids(self) -> None: + lb_repo = AsyncMock() + lb_repo.get_similar_artists.return_value = [ + ListenBrainzSimilarArtist(artist_mbid="sim-1", artist_name="Sim1", listen_count=100), + ] + lb_repo.get_artist_top_release_groups.return_value = [ + ListenBrainzReleaseGroup( + release_group_name="Excluded", artist_name="Sim1", + listen_count=50, release_group_mbid="excluded-rg", + ), + ] + mbid_svc = _make_mbid_svc() + seeds = [ListenBrainzArtist(artist_name="S", listen_count=1, artist_mbids=["s1"])] + pools = await build_similar_artist_pools( + seeds, {"excluded-rg"}, 5, 3, lb_repo=lb_repo, mbid_svc=mbid_svc, + ) + assert pools[0] == [] + + @pytest.mark.asyncio + async def test_seed_without_mbid(self) -> None: + lb_repo = AsyncMock() + mbid_svc = _make_mbid_svc() + seeds = [ListenBrainzArtist(artist_name="NoMBID", listen_count=1, artist_mbids=[])] + pools = await build_similar_artist_pools( + seeds, set(), 5, 3, lb_repo=lb_repo, mbid_svc=mbid_svc, + ) + assert pools == [[]] + lb_repo.get_similar_artists.assert_not_called() + + +# =================================================================== +# discover_by_genres +# =================================================================== + + +class TestDiscoverByGenres: + @pytest.mark.asyncio + async def test_returns_items_from_tag_search(self) -> None: + release = MagicMock() + release.musicbrainz_id = "rg-rock-1" + release.title = "Rock Album" + release.artist = "Rock Artist" + mb_repo = AsyncMock() + mb_repo.search_release_groups_by_tag.return_value = [release] + mbid_svc = _make_mbid_svc() + + result = await discover_by_genres( + ["rock"], set(), + mb_repo=mb_repo, mbid_svc=mbid_svc, + ) + assert len(result) == 1 + assert result[0].release_group_mbid == "rg-rock-1" + + @pytest.mark.asyncio + async def test_returns_empty_no_genres(self) -> None: + mb_repo = AsyncMock() + mbid_svc = _make_mbid_svc() + + result = await discover_by_genres( + [], set(), + mb_repo=mb_repo, mbid_svc=mbid_svc, + ) + assert result == [] + + @pytest.mark.asyncio + async def test_dedup_across_genres(self) -> None: + release = MagicMock() + release.musicbrainz_id = "rg-shared" + release.title = "Shared Album" + release.artist = "Shared Artist" + mb_repo = AsyncMock() + mb_repo.search_release_groups_by_tag.return_value = [release] + mbid_svc = _make_mbid_svc() + + result = await discover_by_genres( + ["rock", "indie"], set(), + mb_repo=mb_repo, mbid_svc=mbid_svc, + ) + assert len(result) == 1 + + +# =================================================================== +# get_artist_deep_cuts +# =================================================================== + + +class TestGetArtistDeepCuts: + @pytest.mark.asyncio + async def test_excludes_current_top_and_listened(self) -> None: + lb_repo = AsyncMock() + top_rg = ListenBrainzReleaseGroup( + release_group_name="Top Album", artist_name="ArtistA", + listen_count=200, release_group_mbid="top-rg", + artist_mbids=["artist-a"], + ) + lb_repo.get_user_top_release_groups.return_value = [top_rg] + deep_rg = ListenBrainzReleaseGroup( + release_group_name="Deep Cut", artist_name="ArtistA", + listen_count=20, release_group_mbid="deep-rg", + ) + listened_rg = ListenBrainzReleaseGroup( + release_group_name="Listened", artist_name="ArtistA", + listen_count=30, release_group_mbid="listened-rg", + ) + lb_repo.get_artist_top_release_groups.return_value = [top_rg, deep_rg, listened_rg] + mbid_svc = _make_mbid_svc() + + result = await get_artist_deep_cuts( + "user1", set(), {"listened-rg"}, 3, + lb_repo=lb_repo, mbid_svc=mbid_svc, + ) + rg_ids = [it.release_group_mbid for it in result] + assert "deep-rg" in rg_ids + assert "top-rg" not in rg_ids + assert "listened-rg" not in rg_ids + + @pytest.mark.asyncio + async def test_returns_empty_no_top_release_groups(self) -> None: + lb_repo = AsyncMock() + lb_repo.get_user_top_release_groups.return_value = [] + mbid_svc = _make_mbid_svc() + + result = await get_artist_deep_cuts( + "user1", set(), set(), 3, + lb_repo=lb_repo, mbid_svc=mbid_svc, + ) + assert result == [] + + @pytest.mark.asyncio + async def test_caps_artist_seeds_at_six(self) -> None: + lb_repo = AsyncMock() + top_rgs = [ + ListenBrainzReleaseGroup( + release_group_name=f"Album{i}", artist_name=f"Artist{i}", + listen_count=200 - i, release_group_mbid=f"rg-{i}", + artist_mbids=[f"artist-{i}"], + ) + for i in range(10) + ] + lb_repo.get_user_top_release_groups.return_value = top_rgs + lb_repo.get_artist_top_release_groups.return_value = [] + mbid_svc = _make_mbid_svc() + + await get_artist_deep_cuts( + "user1", set(), set(), 3, + lb_repo=lb_repo, mbid_svc=mbid_svc, + ) + assert lb_repo.get_artist_top_release_groups.call_count == 6 + + +# =================================================================== +# get_trending_filler +# =================================================================== + + +class TestGetTrendingFiller: + @pytest.mark.asyncio + async def test_lb_path_returns_wildcards(self) -> None: + lb_repo = AsyncMock() + lb_repo.get_sitewide_top_release_groups.return_value = [ + ListenBrainzReleaseGroup( + release_group_name="Trending", artist_name="Artist", + listen_count=500, release_group_mbid="t-1", + artist_mbids=["a-1"], + ), + ] + mb_repo = AsyncMock() + mbid_svc = _make_mbid_svc() + + result = await get_trending_filler( + 5, set(), set(), None, "listenbrainz", + lb_repo=lb_repo, mb_repo=mb_repo, mbid_svc=mbid_svc, + ) + assert len(result) >= 1 + assert result[0].is_wildcard is True + + @pytest.mark.asyncio + async def test_respects_count_cap(self) -> None: + lb_repo = AsyncMock() + lb_repo.get_sitewide_top_release_groups.return_value = [ + ListenBrainzReleaseGroup( + release_group_name=f"T{i}", artist_name=f"A{i}", + listen_count=100, release_group_mbid=f"t-{i}", + artist_mbids=[f"a-{i}"], + ) + for i in range(20) + ] + mb_repo = AsyncMock() + mbid_svc = _make_mbid_svc() + + result = await get_trending_filler( + 3, set(), set(), None, "listenbrainz", + lb_repo=lb_repo, mb_repo=mb_repo, mbid_svc=mbid_svc, + ) + assert len(result) <= 3 + + @pytest.mark.asyncio + async def test_excludes_ignored_library_seen(self) -> None: + lb_repo = AsyncMock() + lb_repo.get_sitewide_top_release_groups.return_value = [ + ListenBrainzReleaseGroup( + release_group_name="Ignored", artist_name="A", + listen_count=100, release_group_mbid="ignored-1", + artist_mbids=["a-1"], + ), + ListenBrainzReleaseGroup( + release_group_name="InLib", artist_name="A", + listen_count=100, release_group_mbid="lib-1", + artist_mbids=["a-2"], + ), + ListenBrainzReleaseGroup( + release_group_name="Seen", artist_name="A", + listen_count=100, release_group_mbid="seen-1", + artist_mbids=["a-3"], + ), + ListenBrainzReleaseGroup( + release_group_name="OK", artist_name="A", + listen_count=100, release_group_mbid="ok-1", + artist_mbids=["a-4"], + ), + ] + mb_repo = AsyncMock() + mbid_svc = _make_mbid_svc() + + result = await get_trending_filler( + 10, {"ignored-1"}, {"lib-1"}, {"seen-1"}, "listenbrainz", + lb_repo=lb_repo, mb_repo=mb_repo, mbid_svc=mbid_svc, + ) + rg_ids = {it.release_group_mbid for it in result} + assert "ignored-1" not in rg_ids + assert "lib-1" not in rg_ids + assert "seen-1" not in rg_ids + assert "ok-1" in rg_ids + + @pytest.mark.asyncio + async def test_returns_empty_when_count_zero(self) -> None: + lb_repo = AsyncMock() + mb_repo = AsyncMock() + mbid_svc = _make_mbid_svc() + + result = await get_trending_filler( + 0, set(), set(), None, "listenbrainz", + lb_repo=lb_repo, mb_repo=mb_repo, mbid_svc=mbid_svc, + ) + assert result == [] + + @pytest.mark.asyncio + async def test_skips_various_artists(self) -> None: + lb_repo = AsyncMock() + lb_repo.get_sitewide_top_release_groups.return_value = [ + ListenBrainzReleaseGroup( + release_group_name="VA Album", artist_name="Various", + listen_count=100, release_group_mbid="va-rg", + artist_mbids=[VARIOUS_ARTISTS_MBID], + ), + ] + mb_repo = AsyncMock() + mbid_svc = _make_mbid_svc() + + result = await get_trending_filler( + 5, set(), set(), None, "listenbrainz", + lb_repo=lb_repo, mb_repo=mb_repo, mbid_svc=mbid_svc, + ) + assert all(it.release_group_mbid != "va-rg" for it in result) + + @pytest.mark.asyncio + async def test_lastfm_happy_path(self) -> None: + """Last.fm source returns trending items when enabled and lfm_repo present.""" + lb_repo = AsyncMock() + mb_repo = AsyncMock() + lfm_repo = AsyncMock() + + artist = MagicMock() + artist.name = "PopStar" + artist.mbid = "pop-artist-1" + lfm_repo.get_global_top_artists.return_value = [artist] + + album = MagicMock() + album.name = "Hit Album" + album.mbid = "lfm-album-1" + lfm_repo.get_artist_top_albums.return_value = [album] + + mbid_svc = _make_mbid_svc() + mbid_svc.lastfm_albums_to_queue_items = AsyncMock(return_value=[ + _item("lfm-rg-1", "pop-artist-1"), + ]) + + result = await get_trending_filler( + 5, set(), set(), None, "lastfm", + lb_repo=lb_repo, mb_repo=mb_repo, mbid_svc=mbid_svc, + lfm_repo=lfm_repo, is_lastfm_enabled=True, + ) + assert len(result) >= 1 + assert result[0].release_group_mbid == "lfm-rg-1" + lfm_repo.get_global_top_artists.assert_awaited_once() + + @pytest.mark.asyncio + async def test_lastfm_fallback_decade_tags(self) -> None: + """When Last.fm primary wildcard fetch returns empty, decade-tag fallback is used.""" + lb_repo = AsyncMock() + mb_repo = AsyncMock() + lfm_repo = AsyncMock() + + artist = MagicMock() + artist.name = "Nobody" + artist.mbid = "nobody-1" + lfm_repo.get_global_top_artists.return_value = [artist] + lfm_repo.get_artist_top_albums.return_value = [] + + mbid_svc = _make_mbid_svc() + mbid_svc.lastfm_albums_to_queue_items = AsyncMock(return_value=[]) + + decade_release = MagicMock() + decade_release.musicbrainz_id = "decade-rg-1" + decade_release.title = "Retro Hit" + decade_release.artist = "Retro Artist" + mb_repo.search_release_groups_by_tag.return_value = [decade_release] + + result = await get_trending_filler( + 3, set(), set(), None, "lastfm", + lb_repo=lb_repo, mb_repo=mb_repo, mbid_svc=mbid_svc, + lfm_repo=lfm_repo, is_lastfm_enabled=True, + ) + assert len(result) >= 1 + assert result[0].release_group_mbid == "decade-rg-1" + mb_repo.search_release_groups_by_tag.assert_called() + + @pytest.mark.asyncio + async def test_lastfm_returns_empty_gracefully_when_disabled(self) -> None: + """When source is lastfm but is_lastfm_enabled is False, falls back to LB path.""" + lb_repo = AsyncMock() + lb_repo.get_sitewide_top_release_groups.return_value = [] + mb_repo = AsyncMock() + mbid_svc = _make_mbid_svc() + + result = await get_trending_filler( + 3, set(), set(), None, "lastfm", + lb_repo=lb_repo, mb_repo=mb_repo, mbid_svc=mbid_svc, + lfm_repo=None, is_lastfm_enabled=False, + ) + assert result == [] + + +# =================================================================== +# Non-mutation assertions (pure-function contract) +# =================================================================== + + +class TestNonMutationContract: + def test_round_robin_dedup_select_does_not_mutate_input(self) -> None: + pool_a = [_item(f"a-{i}", f"art-a-{i}") for i in range(5)] + pool_b = [_item(f"b-{i}", f"art-b-{i}") for i in range(5)] + original_a = list(pool_a) + original_b = list(pool_b) + + round_robin_dedup_select([pool_a, pool_b], 4) + + assert pool_a == original_a, "round_robin_dedup_select mutated pool_a" + assert pool_b == original_b, "round_robin_dedup_select mutated pool_b" + + def test_interleave_at_positions_does_not_mutate_input(self) -> None: + base = [_item(f"base-{i}") for i in range(5)] + insertions = [_item("ins-0", "w"), _item("ins-1", "w")] + original_base = list(base) + original_ins = list(insertions) + + interleave_at_positions(base, insertions) + + assert base == original_base, "interleave_at_positions mutated base" + assert insertions == original_ins, "interleave_at_positions mutated insertions" diff --git a/backend/tests/services/test_unexplored_genres.py b/backend/tests/services/test_unexplored_genres.py new file mode 100644 index 0000000..0ce1027 --- /dev/null +++ b/backend/tests/services/test_unexplored_genres.py @@ -0,0 +1,395 @@ +"""Tests for the Unexplored Genres section builder in DiscoverHomepageService.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from api.v1.schemas.discover import BecauseYouListenTo, DiscoverResponse +from api.v1.schemas.home import HomeSection, HomeArtist, HomeGenre +from api.v1.schemas.settings import ( + ListenBrainzConnectionSettings, + LastFmConnectionSettings, + PrimaryMusicSourceSettings, +) +from services.discover.homepage_service import ( + DiscoverHomepageService, + UNEXPLORED_GENRES_THRESHOLD, + UNEXPLORED_GENRES_MAX, +) +from services.discover.integration_helpers import IntegrationHelpers + + +# ---- helpers ---- + +def _make_lb_settings( + enabled: bool = True, username: str = "lbuser", +) -> ListenBrainzConnectionSettings: + return ListenBrainzConnectionSettings( + user_token="tok", username=username, enabled=enabled, + ) + + +def _make_lfm_settings( + enabled: bool = True, username: str = "lfmuser", +) -> LastFmConnectionSettings: + return LastFmConnectionSettings( + api_key="key", shared_secret="secret", session_key="sk", + username=username, enabled=enabled, + ) + + +def _make_prefs( + lb_enabled: bool = True, + lfm_enabled: bool = False, + primary_source: str = "listenbrainz", +) -> MagicMock: + prefs = MagicMock() + prefs.get_listenbrainz_connection.return_value = _make_lb_settings(enabled=lb_enabled) + prefs.get_lastfm_connection.return_value = _make_lfm_settings(enabled=lfm_enabled) + prefs.is_lastfm_enabled.return_value = lfm_enabled + prefs.get_primary_music_source.return_value = PrimaryMusicSourceSettings(source=primary_source) + + jf_settings = MagicMock() + jf_settings.enabled = False + jf_settings.jellyfin_url = "" + jf_settings.api_key = "" + prefs.get_jellyfin_connection.return_value = jf_settings + + lidarr = MagicMock() + lidarr.lidarr_url = "" + lidarr.lidarr_api_key = "" + prefs.get_lidarr_connection.return_value = lidarr + + yt = MagicMock() + yt.enabled = False + yt.api_key = "" + prefs.get_youtube_connection.return_value = yt + + lf = MagicMock() + lf.enabled = False + lf.music_path = "" + prefs.get_local_files_connection.return_value = lf + + adv = MagicMock() + adv.discover_queue_size = 10 + adv.discover_queue_ttl = 3600 + adv.discover_queue_seed_artists = 3 + adv.discover_queue_wildcard_slots = 2 + adv.discover_queue_similar_artists_limit = 15 + adv.discover_queue_albums_per_similar = 3 + adv.discover_queue_enrich_ttl = 3600 + adv.discover_queue_lastfm_mbid_max_lookups = 10 + prefs.get_advanced_settings.return_value = adv + + return prefs + + +def _make_genre_index( + genres_for_artists: dict[str, list[str]] | None = None, + genre_artist_counts: dict[str, int] | None = None, + top_genres: list[tuple[str, int]] | None = None, + underrepresented_genres: list[str] | None = None, +) -> AsyncMock: + gi = AsyncMock() + gi.get_genres_for_artists = AsyncMock(return_value=genres_for_artists or {}) + gi.get_genre_artist_counts = AsyncMock(return_value=genre_artist_counts or {}) + gi.get_top_genres = AsyncMock(return_value=top_genres or []) + gi.get_underrepresented_genres = AsyncMock(return_value=underrepresented_genres or []) + return gi + + +def _make_service( + genre_index: AsyncMock | None = None, +) -> DiscoverHomepageService: + lb_repo = AsyncMock() + jf_repo = AsyncMock() + lidarr_repo = AsyncMock() + mb_repo = AsyncMock() + prefs = _make_prefs() + integration = IntegrationHelpers(prefs) + mbid_resolution = MagicMock() + + return DiscoverHomepageService( + listenbrainz_repo=lb_repo, + jellyfin_repo=jf_repo, + lidarr_repo=lidarr_repo, + musicbrainz_repo=mb_repo, + integration=integration, + mbid_resolution=mbid_resolution, + genre_index=genre_index, + ) + + +def _make_because_section( + seed_name: str = "Radiohead", + seed_mbid: str = "seed-mbid-1", + artist_items: list[HomeArtist] | None = None, +) -> BecauseYouListenTo: + items = artist_items or [] + return BecauseYouListenTo( + seed_artist=seed_name, + seed_artist_mbid=seed_mbid, + section=HomeSection( + title=f"Because You Listen To {seed_name}", + type="artists", + items=items, + source="listenbrainz", + ), + ) + + +# --------------------------------------------------------------------------- +# Test 1 - returns None when genre_index is None +# --------------------------------------------------------------------------- +class TestReturnsNoneWhenGenreIndexIsNone: + @pytest.mark.asyncio + async def test_returns_none_when_genre_index_is_none(self) -> None: + service = _make_service(genre_index=None) + result = await service._build_unexplored_genres([], []) + assert result is None + + +# --------------------------------------------------------------------------- +# Test 2 - returns None when no because sections and no similar mbids +# --------------------------------------------------------------------------- +class TestReturnsNoneWhenEmpty: + @pytest.mark.asyncio + async def test_returns_none_when_no_because_sections_and_no_similar_mbids(self) -> None: + gi = _make_genre_index() + service = _make_service(genre_index=gi) + result = await service._build_unexplored_genres([], []) + assert result is None + + +# --------------------------------------------------------------------------- +# Test 3 - returns section with underrepresented genres +# --------------------------------------------------------------------------- +class TestReturnsSectionWithUnderrepresentedGenres: + @pytest.mark.asyncio + async def test_returns_section_with_underrepresented_genres(self) -> None: + gi = _make_genre_index( + genres_for_artists={ + "artist-1": ["Post-Punk", "Shoegaze"], + "artist-2": ["Ambient"], + }, + genre_artist_counts={"post-punk": 1, "shoegaze": 0, "ambient": 1}, + top_genres=[("rock", 50), ("pop", 30)], + ) + service = _make_service(genre_index=gi) + + sections = [_make_because_section(artist_items=[])] + similar = ["artist-1", "artist-2"] + + result = await service._build_unexplored_genres(sections, similar) + assert result is not None + assert len(result.items) == 3 + names = {item.name for item in result.items} + assert "Post-Punk" in names + assert "Shoegaze" in names + assert "Ambient" in names + + +# --------------------------------------------------------------------------- +# Test 4 - filters genres at or above threshold +# --------------------------------------------------------------------------- +class TestFiltersGenresAtOrAboveThreshold: + @pytest.mark.asyncio + async def test_filters_genres_at_or_above_threshold(self) -> None: + gi = _make_genre_index( + genres_for_artists={"a1": ["Rock", "Shoegaze"]}, + genre_artist_counts={"rock": 5, "shoegaze": 1}, + top_genres=[("pop", 30)], + ) + service = _make_service(genre_index=gi) + result = await service._build_unexplored_genres([], ["a1"]) + assert result is not None + names = {item.name for item in result.items} + assert "Rock" not in names + assert "Shoegaze" in names + + +# --------------------------------------------------------------------------- +# Test 5 - excludes user top genres +# --------------------------------------------------------------------------- +class TestExcludesUserTopGenres: + @pytest.mark.asyncio + async def test_excludes_user_top_genres(self) -> None: + gi = _make_genre_index( + genres_for_artists={"a1": ["Rock", "Ambient"]}, + genre_artist_counts={"rock": 0, "ambient": 0}, + top_genres=[("rock", 50)], + ) + service = _make_service(genre_index=gi) + result = await service._build_unexplored_genres([], ["a1"]) + assert result is not None + names = {item.name for item in result.items} + assert "Rock" not in names + assert "Ambient" in names + + +# --------------------------------------------------------------------------- +# Test 6 - caps at 8 genres +# --------------------------------------------------------------------------- +class TestCapsAt8Genres: + @pytest.mark.asyncio + async def test_caps_at_8_genres(self) -> None: + genre_names = [f"Genre-{i}" for i in range(12)] + gi = _make_genre_index( + genres_for_artists={"a1": genre_names}, + genre_artist_counts={}, + top_genres=[], + ) + service = _make_service(genre_index=gi) + result = await service._build_unexplored_genres([], ["a1"]) + assert result is not None + assert len(result.items) == UNEXPLORED_GENRES_MAX + + +# --------------------------------------------------------------------------- +# Test 7 - section type is genres +# --------------------------------------------------------------------------- +class TestSectionTypeIsGenres: + @pytest.mark.asyncio + async def test_section_type_is_genres(self) -> None: + gi = _make_genre_index( + genres_for_artists={"a1": ["Post-Punk"]}, + genre_artist_counts={}, + top_genres=[], + ) + service = _make_service(genre_index=gi) + result = await service._build_unexplored_genres([], ["a1"]) + assert result is not None + assert result.type == "genres" + + +# --------------------------------------------------------------------------- +# Test 8 - section title +# --------------------------------------------------------------------------- +class TestSectionTitle: + @pytest.mark.asyncio + async def test_section_title(self) -> None: + gi = _make_genre_index( + genres_for_artists={"a1": ["Post-Punk"]}, + genre_artist_counts={}, + top_genres=[], + ) + service = _make_service(genre_index=gi) + result = await service._build_unexplored_genres([], ["a1"]) + assert result is not None + assert result.title == "Genres to Explore" + + +# --------------------------------------------------------------------------- +# Test 9 - fallback to get_underrepresented_genres +# --------------------------------------------------------------------------- +class TestFallbackToGetUnderrepresentedGenres: + @pytest.mark.asyncio + async def test_fallback_to_get_underrepresented_genres(self) -> None: + gi = _make_genre_index( + genres_for_artists={"a1": ["Rock"]}, + genre_artist_counts={"rock": 5}, + top_genres=[], + underrepresented_genres=["shoegaze", "ambient"], + ) + service = _make_service(genre_index=gi) + result = await service._build_unexplored_genres([], ["a1"]) + assert result is not None + gi.get_underrepresented_genres.assert_awaited_once() + names = {item.name for item in result.items} + assert "Shoegaze" in names + assert "Ambient" in names + + +# --------------------------------------------------------------------------- +# Test 10 - returns None when fallback also empty +# --------------------------------------------------------------------------- +class TestReturnsNoneWhenFallbackAlsoEmpty: + @pytest.mark.asyncio + async def test_returns_none_when_fallback_also_empty(self) -> None: + gi = _make_genre_index( + genres_for_artists={"a1": ["Rock"]}, + genre_artist_counts={"rock": 5}, + top_genres=[], + underrepresented_genres=[], + ) + service = _make_service(genre_index=gi) + result = await service._build_unexplored_genres([], ["a1"]) + assert result is None + + +# --------------------------------------------------------------------------- +# Test 11 - genre items have artist_count +# --------------------------------------------------------------------------- +class TestGenreItemsHaveArtistCount: + @pytest.mark.asyncio + async def test_genre_items_have_artist_count(self) -> None: + gi = _make_genre_index( + genres_for_artists={"a1": ["Post-Punk", "Ambient"]}, + genre_artist_counts={"post-punk": 1, "ambient": 0}, + top_genres=[], + ) + service = _make_service(genre_index=gi) + result = await service._build_unexplored_genres([], ["a1"]) + assert result is not None + counts_by_name = {item.name: item.artist_count for item in result.items} + assert counts_by_name["Post-Punk"] == 1 + assert counts_by_name["Ambient"] == 0 + + +# --------------------------------------------------------------------------- +# Test 12 - candidate MBIDs from because sections and similar +# --------------------------------------------------------------------------- +class TestCandidateMbidsFromBothSources: + @pytest.mark.asyncio + async def test_candidate_mbids_from_because_sections_and_similar(self) -> None: + gi = _make_genre_index( + genres_for_artists={ + "because-mbid": ["Shoegaze"], + "similar-mbid": ["Ambient"], + }, + genre_artist_counts={}, + top_genres=[], + ) + service = _make_service(genre_index=gi) + + because = [_make_because_section( + artist_items=[HomeArtist(name="Artist A", mbid="because-mbid")] + )] + similar = ["similar-mbid"] + + result = await service._build_unexplored_genres(because, similar) + assert result is not None + + call_args = gi.get_genres_for_artists.call_args[0][0] + assert "because-mbid" in call_args + assert "similar-mbid" in call_args + + +# --------------------------------------------------------------------------- +# Test 13 - _has_meaningful_content with unexplored_genres +# --------------------------------------------------------------------------- +class TestHasMeaningfulContentWithUnexploredGenres: + def test_has_meaningful_content_with_unexplored_genres(self) -> None: + service = _make_service() + section = HomeSection(title="Genres to Explore", type="genres", items=[ + HomeGenre(name="Ambient", artist_count=0), + ]) + response = DiscoverResponse(unexplored_genres=section) + assert service._has_meaningful_content(response) is True + + def test_has_meaningful_content_empty_response(self) -> None: + service = _make_service() + response = DiscoverResponse() + assert service._has_meaningful_content(response) is False + + +# --------------------------------------------------------------------------- +# Test 14 - exception returns None +# --------------------------------------------------------------------------- +class TestExceptionReturnsNone: + @pytest.mark.asyncio + async def test_exception_returns_none(self) -> None: + gi = _make_genre_index() + gi.get_genres_for_artists = AsyncMock(side_effect=RuntimeError("db error")) + service = _make_service(genre_index=gi) + result = await service._build_unexplored_genres([], ["a1"]) + assert result is None diff --git a/frontend/src/lib/actions/tilt.ts b/frontend/src/lib/actions/tilt.ts new file mode 100644 index 0000000..45b19ef --- /dev/null +++ b/frontend/src/lib/actions/tilt.ts @@ -0,0 +1,70 @@ +let reducedMotion = + typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +if (typeof window !== 'undefined') { + window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', (e) => { + reducedMotion = e.matches; + }); +} + +export interface TiltOptions { + /** CSS color variable for the shadow glow, e.g. 'var(--p)' or 'var(--s)' */ + shadowColorVar?: string; +} + +/** + * Svelte action that adds a 3D tilt effect with specular highlight to an element. + * + * Sets CSS custom properties on the node that the component template can consume: + * - `--tilt-transform` - the rotateX/rotateY transform string + * - `--tilt-specular-bg` - radial-gradient for the specular highlight + * - `--tilt-shadow` - box-shadow value (changes on hover) + * + * Respects `prefers-reduced-motion: reduce` reactively. + */ +export function tilt(node: HTMLElement, options?: TiltOptions) { + const color = options?.shadowColorVar ?? 'var(--p)'; + + node.style.setProperty('--tilt-shadow', `0 4px 16px oklch(${color} / 0.06)`); + + function handlePointerMove(e: PointerEvent) { + if (reducedMotion) return; + const rect = node.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const centerX = rect.width / 2; + const centerY = rect.height / 2; + const rotateY = ((x - centerX) / centerX) * 4; + const rotateX = ((centerY - y) / centerY) * 3; + node.style.setProperty( + '--tilt-transform', + `rotateX(${rotateX.toFixed(1)}deg) rotateY(${rotateY.toFixed(1)}deg) translateZ(0)` + ); + const pctX = ((x / rect.width) * 100).toFixed(0); + const pctY = ((y / rect.height) * 100).toFixed(0); + node.style.setProperty( + '--tilt-specular-bg', + `radial-gradient(circle at ${pctX}% ${pctY}%, rgba(255,255,255,0.08) 0%, transparent 60%)` + ); + node.style.setProperty('--tilt-shadow', `0 12px 48px oklch(${color} / 0.2)`); + } + + function handlePointerLeave() { + node.style.removeProperty('--tilt-transform'); + node.style.removeProperty('--tilt-specular-bg'); + node.style.setProperty('--tilt-shadow', `0 4px 16px oklch(${color} / 0.06)`); + } + + node.addEventListener('pointermove', handlePointerMove); + node.addEventListener('pointerleave', handlePointerLeave); + + return { + destroy() { + node.removeEventListener('pointermove', handlePointerMove); + node.removeEventListener('pointerleave', handlePointerLeave); + node.style.removeProperty('--tilt-transform'); + node.style.removeProperty('--tilt-specular-bg'); + node.style.removeProperty('--tilt-shadow'); + } + }; +} diff --git a/frontend/src/lib/api/albums.ts b/frontend/src/lib/api/albums.ts new file mode 100644 index 0000000..9941357 --- /dev/null +++ b/frontend/src/lib/api/albums.ts @@ -0,0 +1,9 @@ +import type { AlbumTracksInfo } from '$lib/types'; +import { api } from '$lib/api/client'; + +export async function fetchAlbumTracks( + albumId: string, + signal?: AbortSignal +): Promise { + return api.global.get(`/api/v1/albums/${albumId}/tracks`, { signal }); +} diff --git a/frontend/src/lib/components/AlbumRequestButton.svelte b/frontend/src/lib/components/AlbumRequestButton.svelte new file mode 100644 index 0000000..f6c08ee --- /dev/null +++ b/frontend/src/lib/components/AlbumRequestButton.svelte @@ -0,0 +1,51 @@ + + + diff --git a/frontend/src/lib/components/BaseImage.svelte b/frontend/src/lib/components/BaseImage.svelte index 0345ea1..eb1964a 100644 --- a/frontend/src/lib/components/BaseImage.svelte +++ b/frontend/src/lib/components/BaseImage.svelte @@ -2,8 +2,9 @@ import { run } from 'svelte/legacy'; import { onDestroy } from 'svelte'; + import { Disc3, Users } from 'lucide-svelte'; import { lazyImage, resetLazyImage } from '$lib/utils/lazyImage'; - import { PLACEHOLDER_COLORS, API_SIZES } from '$lib/constants'; + import { API_SIZES } from '$lib/constants'; import { isValidMbid } from '$lib/utils/formatting'; import { imageSettingsStore } from '$lib/stores/imageSettings'; import { appendAudioDBSizeSuffix } from '$lib/utils/imageSuffix'; @@ -187,51 +188,13 @@ }); -
+
{#if showPlaceholder && (!imgLoaded || imgError || !hasSource)}
{#if imageType === 'album'} - - - - - - - - + {:else} - - - - - + {/if}
{/if} diff --git a/frontend/src/lib/components/BrowseHeroCards.svelte b/frontend/src/lib/components/BrowseHeroCards.svelte index 77e8ea7..6f15b2e 100644 --- a/frontend/src/lib/components/BrowseHeroCards.svelte +++ b/frontend/src/lib/components/BrowseHeroCards.svelte @@ -47,15 +47,14 @@ let hoveredIndex = $state(null); let cardEls: HTMLAnchorElement[] = []; - let cardCount = $derived(cards.length); - let tiltStyles: string[] = $state(Array(cardCount).fill('')); - let specularStyles: string[] = $state(Array(cardCount).fill('')); + let tiltStyles: string[] = $state(Array(cards.length).fill('')); + let specularStyles: string[] = $state(Array(cards.length).fill('')); const tweenDuration = reducedMotion ? 0 : 1200; - const counters: Tweened[] = Array.from({ length: cardCount }, () => + const counters: Tweened[] = Array.from({ length: cards.length }, () => tweened(0, { duration: tweenDuration, easing: cubicOut }) ); - let counterValues: number[] = $state(Array(cardCount).fill(0)); + let counterValues: number[] = $state(Array(cards.length).fill(0)); $effect(() => { const unsubs = counters.map((c, i) => diff --git a/frontend/src/lib/components/DailyMixCard.svelte b/frontend/src/lib/components/DailyMixCard.svelte new file mode 100644 index 0000000..a83efd8 --- /dev/null +++ b/frontend/src/lib/components/DailyMixCard.svelte @@ -0,0 +1,124 @@ + + +
+
+ +
+
+ + +{#if expanded} +
+
+ +
+
+{/if} diff --git a/frontend/src/lib/components/DiscoverArtistMiniBand.svelte b/frontend/src/lib/components/DiscoverArtistMiniBand.svelte new file mode 100644 index 0000000..c49e1fe --- /dev/null +++ b/frontend/src/lib/components/DiscoverArtistMiniBand.svelte @@ -0,0 +1,105 @@ + + +
+ + +
+
+ +
+
+
+ More like +
+

+ {entry.seed_artist} +

+
+ {#if entry.listen_count > 0} + + {/if} +
+ +
+ +
+
+ + diff --git a/frontend/src/lib/components/DiscoverPicksSection.svelte b/frontend/src/lib/components/DiscoverPicksSection.svelte new file mode 100644 index 0000000..fffbc90 --- /dev/null +++ b/frontend/src/lib/components/DiscoverPicksSection.svelte @@ -0,0 +1,255 @@ + + +{#if albums.length > 0} +
+
+
+
+ + + +
+
+

{section.title}

+ +
+

Picked for you

+
+
+
+ +
+ {#if heroAlbum} + {@const heroHref = albumHrefOrNull(heroAlbum.mbid)} + +
+ +
+ + +
+ + Featured Pick +
+ + {#if heroAlbum.in_library} +
+ +
+ {:else if heroAlbum.monitored && !heroAlbum.requested} +
+ +
+ {/if} + + {#if heroAlbum.mbid && heroAlbum.in_library} + + {/if} + + {#if !heroAlbum.mbid} + + {/if} + +
+ +
+

+ {heroAlbum.name} +

+ {#if heroAlbum.artist_name} +

+ {heroAlbum.artist_name} +

+ {/if} +
+
+
+ {#if heroAlbum.mbid} +
+ {#if $integrationStore.lidarr && !heroAlbum.in_library && !(heroAlbum.requested || libraryStore.isRequested(heroAlbum.mbid))} + + {/if} + +
+ {/if} + {/if} + + {#if remainingAlbums.length > 0} +
+ + {#each remainingAlbums as album, i (`${album.name}-${i}`)} + {@const albumHref = albumHrefOrNull(album.mbid)} +
+ +
+ + {#if album.in_library} +
+ +
+ {:else if album.monitored && !album.requested} +
+ +
+ {/if} + {#if album.mbid && album.in_library} + + {/if} + {#if !album.mbid} + + {/if} +
+
+

{album.name}

+ {#if album.artist_name} +

+ {album.artist_name} +

+ {/if} +
+
+ {#if album.mbid} + {@const isAlbumRequested = + album.requested || libraryStore.isRequested(album.mbid)} +
+ {#if $integrationStore.lidarr && !album.in_library && !isAlbumRequested} + + {/if} + +
+ {/if} +
+ {/each} +
+
+ {/if} +
+
+
+{/if} diff --git a/frontend/src/lib/components/DiscoverQueueCard.svelte b/frontend/src/lib/components/DiscoverQueueCard.svelte index 5f21f31..a96be83 100644 --- a/frontend/src/lib/components/DiscoverQueueCard.svelte +++ b/frontend/src/lib/components/DiscoverQueueCard.svelte @@ -1,5 +1,5 @@ + +
+
+ +

{title}

+ {#if onShuffle} + + {/if} +
+ +
+ {#each genres.slice(0, MAX_PILLS) as genre (genre.name)} + + {genre.name} + {#if genre.listen_count} + + {/if} + + {/each} +
+
+ + diff --git a/frontend/src/lib/components/HomeSection.svelte b/frontend/src/lib/components/HomeSection.svelte index a2871a0..23db785 100644 --- a/frontend/src/lib/components/HomeSection.svelte +++ b/frontend/src/lib/components/HomeSection.svelte @@ -6,33 +6,46 @@ HomeTrack, HomeGenre } from '$lib/types'; + import type { Snippet } from 'svelte'; import { ArrowRight, X, Check, Bookmark, + Disc3, Music2, Tv, Sparkles, - Search, - Radio, - Headphones + Search } from 'lucide-svelte'; import { goto } from '$app/navigation'; import { albumHrefOrNull, artistHrefOrNull } from '$lib/utils/entityRoutes'; import { formatListenCount, formatListenedAt } from '$lib/utils/formatting'; + import { integrationStore } from '$lib/stores/integration'; + import { libraryStore } from '$lib/stores/library'; import ArtistImage from './ArtistImage.svelte'; import AlbumImage from './AlbumImage.svelte'; import AlbumCardOverlay from './AlbumCardOverlay.svelte'; + import AlbumRequestButton from './AlbumRequestButton.svelte'; import HorizontalCarousel from './HorizontalCarousel.svelte'; + import SourceBadge from './SourceBadge.svelte'; + import TrackPreviewButton from './TrackPreviewButton.svelte'; interface Props { section: HomeSectionType; showConnectCard?: boolean; headerLink?: string | null; + headerActions?: Snippet; + hideHeader?: boolean; } - let { section, showConnectCard = true, headerLink = null }: Props = $props(); + let { + section, + showConnectCard = true, + headerLink = null, + headerActions, + hideHeader = false + }: Props = $props(); function getGenreHref(genre: HomeGenre): string { return `/genre?name=${encodeURIComponent(genre.name)}`; @@ -73,48 +86,35 @@
-
-
+ {#if !hideHeader} +
+
+ {#if headerLink} + + {section.title} + + {:else} +

{section.title}

+ {/if} + +
+ {#if headerActions} + {@render headerActions()} + {/if} {#if headerLink} - {section.title} + See all + - {:else} -

{section.title}

- {/if} - {#if section.source === 'lastfm'} - - - Last.fm - - {:else if section.source === 'listenbrainz'} - - - ListenBrainz - - {:else if section.source} - {section.source} {/if}
- {#if headerLink} - - See all - - - {/if} -
+ {/if} {#if section.items.length === 0 && section.fallback_message && showConnectCard}
@@ -199,6 +199,7 @@
{:else if isAlbum(item)} {@const albumHref = albumHrefOrNull(item.mbid)} + {@const isItemRequested = item.requested || libraryStore.isRequested(item.mbid)}
+ {#if item.mbid} +
+ {#if $integrationStore.lidarr && !item.in_library && !isItemRequested} + + {/if} + +
+ {/if}
{:else if isTrack(item)} {@const trackArtistHref = artistHrefOrNull(item.artist_mbid)} @@ -275,7 +297,7 @@ /> {:else}
- +
{/if} diff --git a/frontend/src/lib/components/HomeSectionNowPlaying.svelte b/frontend/src/lib/components/HomeSectionNowPlaying.svelte index 028c166..47a03e9 100644 --- a/frontend/src/lib/components/HomeSectionNowPlaying.svelte +++ b/frontend/src/lib/components/HomeSectionNowPlaying.svelte @@ -1,5 +1,5 @@ + + + + + diff --git a/frontend/src/lib/components/PlaylistImportBanner.svelte b/frontend/src/lib/components/PlaylistImportBanner.svelte index 5dfc5a1..ac9355e 100644 --- a/frontend/src/lib/components/PlaylistImportBanner.svelte +++ b/frontend/src/lib/components/PlaylistImportBanner.svelte @@ -1,7 +1,7 @@ {#snippet gridFallback()} -
- +
+
{/snippet} @@ -91,10 +89,8 @@
{:else if urls.length === 1} {#if imageErrors[0]} -
- +
+
{:else} {/if} {:else} -
- +
+
{/if}
diff --git a/frontend/src/lib/components/QueueDrawer.svelte b/frontend/src/lib/components/QueueDrawer.svelte index 40ca7ff..7691ae6 100644 --- a/frontend/src/lib/components/QueueDrawer.svelte +++ b/frontend/src/lib/components/QueueDrawer.svelte @@ -4,7 +4,7 @@ import { playerStore } from '$lib/stores/player.svelte'; import { playbackToast } from '$lib/stores/playbackToast.svelte'; import { getCoverUrl } from '$lib/utils/errorHandling'; - import { X, GripVertical, ListMusic, Music, Shuffle, Trash2 } from 'lucide-svelte'; + import { X, GripVertical, ListMusic, Disc3, Shuffle, Trash2 } from 'lucide-svelte'; import JellyfinIcon from '$lib/components/JellyfinIcon.svelte'; import LocalFilesIcon from '$lib/components/LocalFilesIcon.svelte'; import NavidromeIcon from '$lib/components/NavidromeIcon.svelte'; @@ -294,8 +294,8 @@ {#if coverUrl} {item.albumName} {:else} -
- +
+
{/if}
diff --git a/frontend/src/lib/components/RadioCard.svelte b/frontend/src/lib/components/RadioCard.svelte new file mode 100644 index 0000000..3c5b2c9 --- /dev/null +++ b/frontend/src/lib/components/RadioCard.svelte @@ -0,0 +1,217 @@ + + +{#if section} +
+
+ +
+
+ + {#if expanded} +
+
+
+ +
+ +
+
+ {/if} +{/if} + + diff --git a/frontend/src/lib/components/RadioSection.spec.ts b/frontend/src/lib/components/RadioSection.spec.ts new file mode 100644 index 0000000..08f723b --- /dev/null +++ b/frontend/src/lib/components/RadioSection.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/queries/QueryClient', () => ({ + invalidateQueriesWithPersister: vi.fn().mockResolvedValue(undefined) +})); + +import { DiscoverQueryKeyFactory } from '$lib/queries/discover/DiscoverQueryKeyFactory'; +import { invalidateQueriesWithPersister } from '$lib/queries/QueryClient'; + +const mockInvalidate = vi.mocked(invalidateQueriesWithPersister); + +describe('DiscoverQueryKeyFactory.radio', () => { + it('returns the expected key shape for artist seed', () => { + const key = DiscoverQueryKeyFactory.radio('artist', 'test-mbid', 'listenbrainz'); + expect(key).toEqual(['discover', 'radio', 'artist', 'test-mbid', { source: 'listenbrainz' }]); + }); + + it('returns the expected key shape for album seed', () => { + const key = DiscoverQueryKeyFactory.radio('album', 'album-mbid', 'lastfm'); + expect(key).toEqual(['discover', 'radio', 'album', 'album-mbid', { source: 'lastfm' }]); + }); + + it('generates different keys for different seed types', () => { + const artistKey = DiscoverQueryKeyFactory.radio('artist', 'test-mbid', 'listenbrainz'); + const albumKey = DiscoverQueryKeyFactory.radio('album', 'test-mbid', 'listenbrainz'); + expect(artistKey).not.toEqual(albumKey); + }); + + it('generates different keys for different seed IDs', () => { + const key1 = DiscoverQueryKeyFactory.radio('artist', 'mbid-1', 'listenbrainz'); + const key2 = DiscoverQueryKeyFactory.radio('artist', 'mbid-2', 'listenbrainz'); + expect(key1).not.toEqual(key2); + }); + + it('generates different keys for different sources', () => { + const key1 = DiscoverQueryKeyFactory.radio('artist', 'test-mbid', 'listenbrainz'); + const key2 = DiscoverQueryKeyFactory.radio('artist', 'test-mbid', 'lastfm'); + expect(key1).not.toEqual(key2); + }); + + it('starts with the discover prefix', () => { + const key = DiscoverQueryKeyFactory.radio('artist', 'test-mbid', 'listenbrainz'); + expect(key[0]).toBe('discover'); + expect(key[1]).toBe('radio'); + }); +}); + +describe('RadioSection refresh invalidation contract', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('refresh calls invalidateQueriesWithPersister with correct radio query key', async () => { + const seedType = 'artist'; + const seedId = 'abc-123'; + const source = 'listenbrainz' as const; + + // Reproduce RadioSection.svelte handleRefresh() logic + await invalidateQueriesWithPersister({ + queryKey: DiscoverQueryKeyFactory.radio(seedType, seedId, source) + }); + + expect(mockInvalidate).toHaveBeenCalledOnce(); + expect(mockInvalidate).toHaveBeenCalledWith({ + queryKey: ['discover', 'radio', 'artist', 'abc-123', { source: 'listenbrainz' }] + }); + }); + + it('refresh uses distinct keys for different seeds', async () => { + const source = 'listenbrainz' as const; + + await invalidateQueriesWithPersister({ + queryKey: DiscoverQueryKeyFactory.radio('artist', 'seed-1', source) + }); + await invalidateQueriesWithPersister({ + queryKey: DiscoverQueryKeyFactory.radio('album', 'seed-2', source) + }); + + expect(mockInvalidate).toHaveBeenCalledTimes(2); + expect(mockInvalidate.mock.calls[0][0]).not.toEqual(mockInvalidate.mock.calls[1][0]); + }); +}); diff --git a/frontend/src/lib/components/SearchSuggestions.svelte b/frontend/src/lib/components/SearchSuggestions.svelte index 69f6c35..dffc562 100644 --- a/frontend/src/lib/components/SearchSuggestions.svelte +++ b/frontend/src/lib/components/SearchSuggestions.svelte @@ -1,6 +1,6 @@ + +{#if source === 'lastfm'} + + + Last.fm + +{:else if source === 'listenbrainz'} + + + ListenBrainz + +{:else if source} + {source} +{/if} diff --git a/frontend/src/lib/components/SourcePlaylistCard.svelte b/frontend/src/lib/components/SourcePlaylistCard.svelte index 5770aa3..41dc8cc 100644 --- a/frontend/src/lib/components/SourcePlaylistCard.svelte +++ b/frontend/src/lib/components/SourcePlaylistCard.svelte @@ -1,7 +1,7 @@ -
+
discoverQuery.refetch()}>Try Again
{:else} @@ -344,47 +223,137 @@
{:else if discoverData}
- {#if hasCuratedGroup} + + {#if hasHero} +
+ {#if hasWeeklyExploration && discoverData.weekly_exploration} + + {:else if queueIsHero} + (queueModalOpen = true)} /> + {/if} +
+ {/if} + + +
+
+ {#if showQueueInQuickActions} + (queueModalOpen = true)} /> + {/if} + +
+
+ + + {#if hasMadeForYou}
- + {#snippet icon()}{/snippet} -
- {#if discoverData.because_you_listen_to.length > 0} - {#each discoverData.because_you_listen_to as entry (entry.seed_artist_mbid || entry.seed_artist)} -
- +
+ {#if discoverData.daily_mixes?.length} +
+

+ + Daily Mixes +

+
+ {#each discoverData.daily_mixes as mix, i (`${mix.title}-${i}`)} + + {/each}
- {/each} +
{/if} -
- (queueModalOpen = true)} - /> -
- - {#if activeSource === 'listenbrainz' && discoverData.weekly_exploration && discoverData.weekly_exploration.tracks.length > 0} + {#if discoverData.radio_sections?.length}
- +

+ + Radio Stations +

+
+ {#each discoverData.radio_sections as radio (`radio-${radio.radio_seed_id}`)} + + {/each} +
{/if}
- {:else} + {/if} + + + {#if hasBecauseYouListened}
- (queueModalOpen = true)} /> + + {#snippet icon()}{/snippet} + + +
+ {#each discoverData.because_you_listen_to as entry, i (entry.seed_artist_mbid || entry.seed_artist)} +
+ {#if i === 0} + + {:else} + + {/if} +
+ {/each} + + {#if discoverData.artists_you_might_like && discoverData.artists_you_might_like.items.length > 0} +
+ {/if} + + {#if discoverData.popular_in_your_genres && discoverData.popular_in_your_genres.items.length > 0} +
+ {/if} +
{/if} - {#if hasExploreGroup} + + {#if hasNewFresh}
- + {#snippet icon()}{/snippet} @@ -393,42 +362,52 @@ {/if} + {#if discoverData.discover_picks && discoverData.discover_picks.items.length > 0} + + {/if} + {#if discoverData.missing_essentials && discoverData.missing_essentials.items.length > 0} {/if} - - {#if discoverData.rediscover && discoverData.rediscover.items.length > 0} - - {/if} - - {#if discoverData.artists_you_might_like && discoverData.artists_you_might_like.items.length > 0} - - {/if} - - {#if discoverData.popular_in_your_genres && discoverData.popular_in_your_genres.items.length > 0} - - {/if}
{/if} - {#if hasChartsGroup} + + {#if hasFromYourLibrary}
- - {#snippet icon()}{/snippet} + + {#snippet icon()}{/snippet}
- {#if discoverData.globally_trending && discoverData.globally_trending.items.length > 0} - + {#if discoverData.rediscover && discoverData.rediscover.items.length > 0} + {/if} {#if discoverData.lastfm_recent_scrobbles && discoverData.lastfm_recent_scrobbles.items.length > 0} {/if} +
+
+ {/if} - {#if discoverData.lastfm_weekly_artist_chart && discoverData.lastfm_weekly_artist_chart.items.length > 0} - + + {#if hasBrowseGenres} +
+ + {#snippet icon()}{/snippet} + + +
+ {#if discoverData.unexplored_genres && shuffledGenres.length > 0} +
+ +
{/if} {#if discoverData.genre_list && discoverData.genre_list.items.length > 0} @@ -441,6 +420,25 @@ />
{/if} +
+
+ {/if} + + + {#if hasTrending} +
+ + {#snippet icon()}{/snippet} + + +
+ {#if discoverData.globally_trending && discoverData.globally_trending.items.length > 0} + + {/if} + + {#if discoverData.lastfm_weekly_artist_chart && discoverData.lastfm_weekly_artist_chart.items.length > 0} + + {/if} {#if discoverData.lastfm_weekly_album_chart && discoverData.lastfm_weekly_album_chart.items.length > 0} @@ -457,18 +455,16 @@ Building Your Recommendations

- We're analyzing your listening history and building personalized recommendations. - This may take a moment on first load. + Looking through your listening history to build recommendations. Give it a moment + on first load.

{:else}
-

- Building Recommendations -

+

Still Loading

- Your personalized recommendations are being prepared. Try refreshing in a moment. + Your recommendations are still loading. Try refreshing.

@@ -502,3 +498,4 @@
+ diff --git a/frontend/src/routes/discover/+page.ts b/frontend/src/routes/discover/+page.ts new file mode 100644 index 0000000..356aacd --- /dev/null +++ b/frontend/src/routes/discover/+page.ts @@ -0,0 +1,11 @@ +import { getDiscoverQueryOptions } from '$lib/queries/discover/DiscoverQuery.svelte'; +import { queryClient } from '$lib/queries/QueryClient'; +import { musicSourceStore } from '$lib/stores/musicSource'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async () => { + const source = musicSourceStore.getCachedSource(); + await queryClient.prefetchQuery(getDiscoverQueryOptions(source)); + + return {}; +}; diff --git a/frontend/src/routes/playlists/[id]/PlaylistHeader.svelte b/frontend/src/routes/playlists/[id]/PlaylistHeader.svelte index 25db139..f5ce09c 100644 --- a/frontend/src/routes/playlists/[id]/PlaylistHeader.svelte +++ b/frontend/src/routes/playlists/[id]/PlaylistHeader.svelte @@ -5,15 +5,17 @@ deletePlaylistCover, type PlaylistDetail } from '$lib/api/playlists'; + import PlaylistDiscoveryModal from '$lib/components/PlaylistDiscoveryModal.svelte'; import { toastStore } from '$lib/stores/toast'; import { formatTotalDurationSec, formatRelativeTime } from '$lib/utils/formatting'; import PlaylistMosaic from '$lib/components/PlaylistMosaic.svelte'; import ContextMenu from '$lib/components/ContextMenu.svelte'; import type { MenuItem } from '$lib/components/ContextMenu.svelte'; - import { Play, Shuffle, Pencil, Check, X, Tv } from 'lucide-svelte'; + import { Play, Shuffle, Pencil, Check, X, Tv, Sparkles } from 'lucide-svelte'; import NavidromeIcon from '$lib/components/NavidromeIcon.svelte'; import PlexIcon from '$lib/components/PlexIcon.svelte'; import { getSourceColor, getSourceLabel } from '$lib/utils/sources'; + import { musicSourceStore } from '$lib/stores/musicSource'; interface Props { playlist: PlaylistDetail; @@ -40,6 +42,7 @@ let coverInput = $state(null); let uploading = $state(false); let coverPreview = $state(null); + let discoverModalOpen = $state(false); $effect(() => { if (editingName && nameInputEl) { @@ -317,7 +320,23 @@ Shuffle +
+ + diff --git a/frontend/static/img/cover-placeholder.jpg b/frontend/static/img/cover-placeholder.jpg deleted file mode 100644 index b26cb66..0000000 Binary files a/frontend/static/img/cover-placeholder.jpg and /dev/null differ