Discover page improvements + playlists albums changes (#55)
* MUS-42 Discover page improvements + playlist/album changes + various discovery fixes + tanstack query stuuff + placeholder image fix * fix formatter truncate * fix tests * make lint * fix make ci errors * address copilot
This commit is contained in:
@@ -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)"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user