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:
Harvey
2026-04-17 23:46:52 +00:00
committed by GitHub
parent d4d38e5392
commit 7fd6bb83bd
74 changed files with 8174 additions and 793 deletions
+42
View File
@@ -31,9 +31,16 @@ NPM ?= pnpm
backend-test-coverart-audiodb \
backend-test-dedup-cancellation \
backend-test-discovery \
backend-test-discover-schemas \
backend-test-daily-mix \
backend-test-discover-picks \
backend-test-discover-radio \
backend-test-playlist-suggestions \
backend-test-unexplored-genres \
backend-test-deep-discovery \
backend-test-discovery-precache \
backend-test-exception-handling \
backend-test-genre-index \
backend-test-home \
backend-test-now-playing \
backend-test-home-genre \
@@ -52,6 +59,7 @@ NPM ?= pnpm
backend-test-plex-repository \
backend-test-plex-routes \
backend-test-playlist \
backend-test-queue-strategies \
backend-test-request-queue \
backend-test-request-service \
backend-test-search-top-result \
@@ -62,11 +70,14 @@ NPM ?= pnpm
backend-test-sync-watchdog \
backend-test-content-enrichment \
backend-test-peer-review-fixes \
backend-test-discover-all \
test-discover-all \
test-audiodb-all test-mus14-all test-sync-all \
frontend-install frontend-build frontend-browser-install \
frontend-format-check frontend-check frontend-lint frontend-test frontend-test-server \
frontend-test-album-page \
frontend-test-audiodb-images \
frontend-test-discover-page \
frontend-test-jellyfin \
frontend-test-monitored-artists \
frontend-test-navidrome \
@@ -147,6 +158,27 @@ backend-test-discovery-precache: $(BACKEND_VENV_STAMP) ## Run artist discovery p
backend-test-exception-handling: $(BACKEND_VENV_STAMP) ## Run exception-handling regressions
$(PYTEST) tests/routes/test_scrobble_routes.py tests/routes/test_scrobble_settings_routes.py tests/test_error_leakage.py tests/test_background_task_logging.py
backend-test-genre-index: $(BACKEND_VENV_STAMP) ## Run genre index tests
$(PYTEST) tests/infrastructure/test_genre_index.py -v
backend-test-discover-schemas: $(BACKEND_VENV_STAMP) ## Run discover schema roundtrip tests
$(PYTEST) tests/schemas/test_discover_schemas.py -v
backend-test-daily-mix: $(BACKEND_VENV_STAMP) ## Run daily mix section builder tests
$(PYTEST) tests/services/test_daily_mix.py -v
backend-test-discover-picks: $(BACKEND_VENV_STAMP) ## Run discover picks section builder tests
$(PYTEST) tests/services/test_discover_picks.py -v
backend-test-discover-radio: $(BACKEND_VENV_STAMP) ## Run discover radio tests
$(PYTEST) tests/services/test_discover_radio.py tests/routes/test_discover_radio_routes.py -v
backend-test-playlist-suggestions: $(BACKEND_VENV_STAMP) ## Run playlist suggestion tests
$(PYTEST) tests/services/test_playlist_suggestions.py tests/routes/test_playlist_suggestions_routes.py -v
backend-test-unexplored-genres: $(BACKEND_VENV_STAMP) ## Run unexplored genres tests
$(PYTEST) tests/services/test_unexplored_genres.py -v
backend-test-now-playing: $(BACKEND_VENV_STAMP) ## Run now-playing service and route tests
$(PYTEST) tests/services/test_now_playing.py tests/routes/test_now_playing_routes.py -v
@@ -195,6 +227,9 @@ backend-test-performance: $(BACKEND_VENV_STAMP) ## Run performance regression te
backend-test-playlist: $(BACKEND_VENV_STAMP) ## Run playlist tests
$(PYTEST) tests/services/test_playlist_service.py tests/services/test_playlist_source_resolution.py tests/repositories/test_playlist_repository.py tests/routes/test_playlist_routes.py
backend-test-queue-strategies: $(BACKEND_VENV_STAMP) ## Run queue strategy extraction tests
$(PYTEST) tests/services/test_queue_strategies.py -v
backend-test-request-queue: $(BACKEND_VENV_STAMP) ## Run MUS-14 request queue tests (dedup, cancel, concurrency)
$(PYTEST) tests/infrastructure/test_request_queue_mus14.py tests/infrastructure/test_queue_persistence.py -v
@@ -237,6 +272,10 @@ backend-test-sync-resume: $(BACKEND_VENV_STAMP) ## Run sync resume-on-failure te
backend-test-sync-watchdog: $(BACKEND_VENV_STAMP) ## Run adaptive watchdog timeout tests
$(PYTEST) tests/test_sync_watchdog.py -v
backend-test-discover-all: backend-test-queue-strategies backend-test-daily-mix backend-test-discover-picks backend-test-discover-radio backend-test-unexplored-genres backend-test-playlist-suggestions backend-test-genre-index ## Run all discover expansion tests
test-discover-all: backend-test-discover-all frontend-test-discover-page ## Run all discover expansion tests (backend + frontend)
test-audiodb-all: backend-test-audiodb backend-test-audiodb-prewarm backend-test-audiodb-settings backend-test-coverart-audiodb backend-test-audiodb-phase8 backend-test-audiodb-phase9 frontend-test-audiodb-images ## Run every AudioDB test target
test-mus14-all: backend-test-request-queue backend-test-artist-lock backend-test-request-service ## Run all MUS-14 request system tests
@@ -293,6 +332,9 @@ frontend-test-navidrome: ## Run Navidrome frontend tests
frontend-test-jellyfin: ## Run Jellyfin frontend tests
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server src/lib/player/jellyfinPlaybackApi.spec.ts
frontend-test-discover-page: ## Run discover page and query tests
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server src/lib/queries/discover/DiscoverQuery.spec.ts
rebuild: ## Rebuild the application
cd "$(ROOT_DIR)" && ./manage.sh --rebuild
+20
View File
@@ -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,
+31
View File
@@ -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
+2
View File
@@ -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)
+36 -1
View File
@@ -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)
+603 -4
View File
@@ -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
+29 -296
View File
@@ -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
+265
View File
@@ -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,
)
+30 -1
View File
@@ -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
View File
@@ -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)
+403
View File
@@ -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
+70
View File
@@ -0,0 +1,70 @@
let reducedMotion =
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (typeof window !== 'undefined') {
window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', (e) => {
reducedMotion = e.matches;
});
}
export interface TiltOptions {
/** CSS color variable for the shadow glow, e.g. 'var(--p)' or 'var(--s)' */
shadowColorVar?: string;
}
/**
* Svelte action that adds a 3D tilt effect with specular highlight to an element.
*
* Sets CSS custom properties on the node that the component template can consume:
* - `--tilt-transform` - the rotateX/rotateY transform string
* - `--tilt-specular-bg` - radial-gradient for the specular highlight
* - `--tilt-shadow` - box-shadow value (changes on hover)
*
* Respects `prefers-reduced-motion: reduce` reactively.
*/
export function tilt(node: HTMLElement, options?: TiltOptions) {
const color = options?.shadowColorVar ?? 'var(--p)';
node.style.setProperty('--tilt-shadow', `0 4px 16px oklch(${color} / 0.06)`);
function handlePointerMove(e: PointerEvent) {
if (reducedMotion) return;
const rect = node.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const rotateY = ((x - centerX) / centerX) * 4;
const rotateX = ((centerY - y) / centerY) * 3;
node.style.setProperty(
'--tilt-transform',
`rotateX(${rotateX.toFixed(1)}deg) rotateY(${rotateY.toFixed(1)}deg) translateZ(0)`
);
const pctX = ((x / rect.width) * 100).toFixed(0);
const pctY = ((y / rect.height) * 100).toFixed(0);
node.style.setProperty(
'--tilt-specular-bg',
`radial-gradient(circle at ${pctX}% ${pctY}%, rgba(255,255,255,0.08) 0%, transparent 60%)`
);
node.style.setProperty('--tilt-shadow', `0 12px 48px oklch(${color} / 0.2)`);
}
function handlePointerLeave() {
node.style.removeProperty('--tilt-transform');
node.style.removeProperty('--tilt-specular-bg');
node.style.setProperty('--tilt-shadow', `0 4px 16px oklch(${color} / 0.06)`);
}
node.addEventListener('pointermove', handlePointerMove);
node.addEventListener('pointerleave', handlePointerLeave);
return {
destroy() {
node.removeEventListener('pointermove', handlePointerMove);
node.removeEventListener('pointerleave', handlePointerLeave);
node.style.removeProperty('--tilt-transform');
node.style.removeProperty('--tilt-specular-bg');
node.style.removeProperty('--tilt-shadow');
}
};
}
+9
View File
@@ -0,0 +1,9 @@
import type { AlbumTracksInfo } from '$lib/types';
import { api } from '$lib/api/client';
export async function fetchAlbumTracks(
albumId: string,
signal?: AbortSignal
): Promise<AlbumTracksInfo> {
return api.global.get<AlbumTracksInfo>(`/api/v1/albums/${albumId}/tracks`, { signal });
}
@@ -0,0 +1,51 @@
<script lang="ts">
import { Download } from 'lucide-svelte';
import { requestAlbum } from '$lib/utils/albumRequest';
import { colors } from '$lib/colors';
interface Props {
mbid: string;
artistName: string;
albumName: string;
artistMbid?: string;
size?: 'sm' | 'md';
}
let { mbid, artistName, albumName, artistMbid, size = 'sm' }: Props = $props();
let requesting = $state(false);
async function handleRequest(e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
if (requesting) return;
requesting = true;
try {
await requestAlbum(mbid, {
artist: artistName || undefined,
album: albumName,
artistMbid
});
} finally {
requesting = false;
}
}
</script>
<button
type="button"
class="btn btn-circle btn-sm {size === 'sm'
? 'min-h-[36px] min-w-[36px]'
: 'min-h-[44px] min-w-[44px]'} border-none shadow-sm active:scale-[0.95]"
style="background-color: {colors.accent};"
title={requesting ? 'Requesting...' : 'Request album'}
aria-label="Request {albumName}"
disabled={requesting}
onclick={handleRequest}
>
{#if requesting}
<span class="loading loading-spinner loading-xs" style="color: {colors.secondary};"></span>
{:else}
<Download class={size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'} color={colors.secondary} />
{/if}
</button>
+5 -42
View File
@@ -2,8 +2,9 @@
import { run } from 'svelte/legacy';
import { onDestroy } from 'svelte';
import { Disc3, Users } from 'lucide-svelte';
import { lazyImage, resetLazyImage } from '$lib/utils/lazyImage';
import { PLACEHOLDER_COLORS, API_SIZES } from '$lib/constants';
import { API_SIZES } from '$lib/constants';
import { isValidMbid } from '$lib/utils/formatting';
import { imageSettingsStore } from '$lib/stores/imageSettings';
import { appendAudioDBSizeSuffix } from '$lib/utils/imageSuffix';
@@ -187,51 +188,13 @@
});
</script>
<div
class="relative overflow-hidden shrink-0 {sizeClass} {roundedClass} {className}"
style="background-color: {PLACEHOLDER_COLORS.DARK};"
>
<div class="relative overflow-hidden shrink-0 bg-base-200 {sizeClass} {roundedClass} {className}">
{#if showPlaceholder && (!imgLoaded || imgError || !hasSource)}
<div class="absolute inset-0 w-full h-full flex items-center justify-center">
{#if imageType === 'album'}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" class="w-full h-full">
<rect fill={PLACEHOLDER_COLORS.DARK} width="200" height="200" />
<circle
cx="100"
cy="100"
r="70"
fill={PLACEHOLDER_COLORS.MEDIUM}
stroke={PLACEHOLDER_COLORS.LIGHT}
stroke-width="2"
/>
<circle
cx="100"
cy="100"
r="50"
fill="none"
stroke={PLACEHOLDER_COLORS.LIGHT}
stroke-width="1"
/>
<circle
cx="100"
cy="100"
r="30"
fill="none"
stroke={PLACEHOLDER_COLORS.LIGHT}
stroke-width="1"
/>
<circle cx="100" cy="100" r="12" fill={PLACEHOLDER_COLORS.LIGHT} />
<circle cx="100" cy="100" r="4" fill={PLACEHOLDER_COLORS.DARK} />
</svg>
<Disc3 class="h-1/3 w-1/3 text-base-content/20" />
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" class="w-full h-full">
<rect fill={PLACEHOLDER_COLORS.DARK} width="200" height="200" />
<circle cx="100" cy="80" r="30" fill={PLACEHOLDER_COLORS.LIGHT} />
<path
d="M60 120 Q100 140 140 120 L140 160 Q100 180 60 160 Z"
fill={PLACEHOLDER_COLORS.LIGHT}
/>
</svg>
<Users class="h-1/3 w-1/3 text-base-content/20" />
{/if}
</div>
{/if}
@@ -47,15 +47,14 @@
let hoveredIndex = $state<number | null>(null);
let cardEls: HTMLAnchorElement[] = [];
let cardCount = $derived(cards.length);
let tiltStyles: string[] = $state(Array(cardCount).fill(''));
let specularStyles: string[] = $state(Array(cardCount).fill(''));
let tiltStyles: string[] = $state(Array(cards.length).fill(''));
let specularStyles: string[] = $state(Array(cards.length).fill(''));
const tweenDuration = reducedMotion ? 0 : 1200;
const counters: Tweened<number>[] = Array.from({ length: cardCount }, () =>
const counters: Tweened<number>[] = Array.from({ length: cards.length }, () =>
tweened(0, { duration: tweenDuration, easing: cubicOut })
);
let counterValues: number[] = $state(Array(cardCount).fill(0));
let counterValues: number[] = $state(Array(cards.length).fill(0));
$effect(() => {
const unsubs = counters.map((c, i) =>
@@ -0,0 +1,124 @@
<script lang="ts">
import type { HomeSection as HomeSectionType, HomeAlbum } from '$lib/types';
import HomeSection from '$lib/components/HomeSection.svelte';
import AlbumImage from '$lib/components/AlbumImage.svelte';
import { ChevronDown, Disc3 } from 'lucide-svelte';
import { tilt } from '$lib/actions/tilt';
import { slide } from 'svelte/transition';
interface Props {
section: HomeSectionType;
}
let { section }: Props = $props();
let expanded = $state(false);
const albumItems = $derived(
section.items.filter((item): item is HomeAlbum => section.type === 'albums')
);
const mosaicAlbums = $derived(albumItems.slice(0, 4));
const albumCount = $derived(albumItems.length);
function toggleExpanded() {
expanded = !expanded;
}
</script>
<div class="w-full">
<div style="perspective: 1200px;">
<button
use:tilt={{ shadowColorVar: 'var(--p)' }}
type="button"
class="group relative w-full overflow-hidden rounded-2xl border transition-all cursor-pointer text-left
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-base-100
active:scale-[0.98]
{expanded ? 'border-primary/30' : 'border-white/10'}"
style="
transform-style: preserve-3d;
transform: var(--tilt-transform, rotateX(0) rotateY(0));
box-shadow: var(--tilt-shadow);
transition: transform 0.5s var(--ease-spring), box-shadow 0.5s var(--ease-spring), border-color 0.3s ease;
"
onclick={toggleExpanded}
aria-expanded={expanded}
aria-label="{section.title} - {section.items.length} albums. {expanded
? 'Collapse'
: 'Expand'} to browse."
>
<div class="absolute inset-0 grid grid-cols-2 grid-rows-2" style="height: 160px;">
{#each mosaicAlbums as album, i (album.mbid ?? `${album.name}-${i}`)}
<div class="relative overflow-hidden">
<AlbumImage
mbid={album.mbid || ''}
alt={album.name}
size="sm"
rounded="none"
className="w-full h-full"
customUrl={album.image_url || null}
/>
</div>
{:else}
<!-- Fallback if fewer than 4 albums -->
{#each Array(4) as _, i (i)}
<div class="bg-base-200 flex items-center justify-center">
<Disc3 class="h-6 w-6 text-base-content/20" />
</div>
{/each}
{/each}
</div>
<div
class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/40 to-black/20"
style="height: 160px;"
></div>
<div
class="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-br from-white/[0.04] to-transparent"
style="height: 160px;"
></div>
<div
class="pointer-events-none absolute inset-0 rounded-2xl"
style="background: var(--tilt-specular-bg, transparent); height: 160px;"
></div>
<div class="relative flex items-end justify-between p-4" style="height: 160px;">
<div class="flex flex-col gap-1.5 min-w-0 flex-1 mr-3">
<h3
class="text-sm sm:text-base font-bold text-white leading-tight line-clamp-2 drop-shadow-lg"
>
{section.title}
</h3>
<div class="flex items-center gap-2">
<span
class="inline-flex items-center gap-1 rounded-full bg-black/40 backdrop-blur-sm px-2 py-0.5 text-xs text-white/70"
>
<Disc3 class="h-3 w-3" />
{albumCount} album{albumCount !== 1 ? 's' : ''}
</span>
</div>
</div>
<div
class="flex items-center justify-center h-8 w-8 rounded-full bg-black/40 backdrop-blur-sm border border-white/10 transition-all
group-hover:bg-primary/20 group-hover:border-primary/30"
>
<ChevronDown
class="h-4 w-4 text-white/80 transition-transform duration-300
{expanded ? 'rotate-180' : ''}"
/>
</div>
</div>
</button>
</div>
</div>
<!-- Expanded carousel (full grid width) -->
{#if expanded}
<div transition:slide={{ duration: 300 }} class="col-span-full overflow-hidden">
<div class="pt-3">
<HomeSection {section} showConnectCard={false} />
</div>
</div>
{/if}
@@ -0,0 +1,105 @@
<script lang="ts">
import { getApiUrl } from '$lib/api/api-utils';
import type { BecauseYouListenTo } from '$lib/types';
import HomeSection from './HomeSection.svelte';
import HeroBackdrop from './HeroBackdrop.svelte';
import ArtistImage from './ArtistImage.svelte';
import { Headphones } from 'lucide-svelte';
import { imageSettingsStore } from '$lib/stores/imageSettings';
interface Props {
entry: BecauseYouListenTo;
}
let { entry }: Props = $props();
let hasDirectBackdrop = $derived(
$imageSettingsStore.directRemoteImagesEnabled &&
!!(entry.banner_url || entry.wide_thumb_url || entry.fanart_url)
);
let backdropUrl = $derived.by(() => {
if ($imageSettingsStore.directRemoteImagesEnabled) {
if (entry.banner_url) return entry.banner_url;
if (entry.wide_thumb_url) return entry.wide_thumb_url;
if (entry.fanart_url) return entry.fanart_url;
}
return entry.seed_artist_mbid
? getApiUrl(`/api/v1/covers/artist/${entry.seed_artist_mbid}?size=500`)
: null;
});
let avatarRemoteUrl = $derived.by(() => {
if ($imageSettingsStore.directRemoteImagesEnabled) {
if (entry.banner_url) return entry.banner_url;
if (entry.wide_thumb_url) return entry.wide_thumb_url;
if (entry.fanart_url) return entry.fanart_url;
}
return null;
});
</script>
<article
class="mini-band group relative overflow-hidden rounded-2xl transition-all duration-500 hover:shadow-[0_0_28px_rgb(var(--brand-discover)/0.1)]"
>
<HeroBackdrop
imageUrl={backdropUrl}
opacity={hasDirectBackdrop ? 0.14 : 0.08}
hoverOpacity={hasDirectBackdrop ? 0.2 : 0.12}
blur={hasDirectBackdrop ? 0 : 2}
hoverBlur={hasDirectBackdrop ? 0 : 1}
position="full"
/>
<div class="relative flex items-center gap-3 px-4 pt-4 pb-2 sm:px-5 sm:pt-5">
<div
class="relative h-11 w-11 sm:h-12 sm:w-12 shrink-0 overflow-hidden rounded-full ring-2 ring-[rgb(var(--brand-discover)/0.35)] shadow-[0_0_18px_rgb(var(--brand-discover)/0.25)]"
>
<ArtistImage
mbid={entry.seed_artist_mbid}
alt={entry.seed_artist}
size="sm"
rounded="full"
remoteUrl={avatarRemoteUrl}
/>
</div>
<div class="flex-1 min-w-0">
<div
class="text-[10px] sm:text-xs font-semibold uppercase tracking-widest text-base-content/40 leading-none mb-1"
>
More like
</div>
<h3 class="text-base sm:text-lg font-bold text-primary truncate leading-tight">
{entry.seed_artist}
</h3>
</div>
{#if entry.listen_count > 0}
<span
class="hidden sm:inline-flex items-center gap-1.5 shrink-0 rounded-full px-2.5 py-1 text-xs font-medium"
style="background: rgb(var(--brand-discover) / 0.12); color: rgb(var(--brand-discover));"
>
<Headphones class="w-3 h-3" />
{entry.listen_count}
</span>
{/if}
</div>
<div class="relative px-4 sm:px-5 pb-1">
<HomeSection section={entry.section} hideHeader />
</div>
</article>
<style>
.mini-band {
background: linear-gradient(
135deg,
rgb(var(--brand-discover) / 0.05) 0%,
rgb(var(--brand-discover) / 0.015) 50%,
transparent 100%
);
border: 1px solid rgb(var(--brand-discover) / 0.08);
}
.mini-band:hover {
border-color: rgb(var(--brand-discover) / 0.18);
}
</style>
@@ -0,0 +1,255 @@
<script lang="ts">
import type { HomeSection as HomeSectionType, HomeAlbum } from '$lib/types';
import AlbumImage from '$lib/components/AlbumImage.svelte';
import AlbumCardOverlay from '$lib/components/AlbumCardOverlay.svelte';
import AlbumRequestButton from '$lib/components/AlbumRequestButton.svelte';
import HorizontalCarousel from '$lib/components/HorizontalCarousel.svelte';
import TrackPreviewButton from '$lib/components/TrackPreviewButton.svelte';
import SourceBadge from '$lib/components/SourceBadge.svelte';
import { Sparkles, Check, Bookmark, Search } from 'lucide-svelte';
import { albumHrefOrNull } from '$lib/utils/entityRoutes';
import { goto } from '$app/navigation';
import { integrationStore } from '$lib/stores/integration';
import { libraryStore } from '$lib/stores/library';
interface Props {
section: HomeSectionType;
}
let { section }: Props = $props();
let albums = $derived(section.items as HomeAlbum[]);
let heroAlbum = $derived(albums[0] ?? null);
let remainingAlbums = $derived(albums.slice(1));
function handleAlbumSearch(album: HomeAlbum) {
const query = [album.artist_name, album.name].filter(Boolean).join(' ').trim();
if (query) {
goto(`/search/albums?q=${encodeURIComponent(query)}`);
}
}
</script>
{#if albums.length > 0}
<section class="mb-6 sm:mb-8">
<div
class="rounded-2xl border border-primary/15 bg-gradient-to-br from-primary/8 via-base-200/50 to-secondary/8 p-5 sm:p-6 backdrop-blur-sm shadow-[0_4px_24px_oklch(from_var(--color-primary)_l_c_h/0.08)]"
>
<div class="mb-4 flex items-center gap-3">
<div class="flex items-center gap-2">
<span class="animate-glow-pulse rounded-lg p-1">
<Sparkles class="h-5 w-5 text-primary" />
</span>
<div>
<div class="flex items-center gap-2">
<h2 class="text-lg font-bold sm:text-xl">{section.title}</h2>
<SourceBadge source={section.source ?? undefined} />
</div>
<p class="text-xs text-base-content/50">Picked for you</p>
</div>
</div>
</div>
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:gap-5">
{#if heroAlbum}
{@const heroHref = albumHrefOrNull(heroAlbum.mbid)}
<svelte:element
this={heroHref ? 'a' : 'div'}
href={heroHref ?? undefined}
data-sveltekit-preload-data={heroHref ? 'hover' : undefined}
class="group relative w-full shrink-0 overflow-hidden rounded-xl transition-all duration-300 lg:w-[200px] {heroHref
? 'cursor-pointer motion-safe:hover:scale-[1.02] hover:shadow-[0_0_30px_oklch(from_var(--color-primary)_l_c_h/0.2)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-base-100'
: 'cursor-default'}"
>
<div
class="animate-glow-pulse pointer-events-none absolute inset-0 z-10 rounded-xl"
></div>
<div class="relative aspect-square overflow-hidden rounded-xl">
<AlbumImage
mbid={heroAlbum.mbid || ''}
alt={heroAlbum.name}
size="md"
rounded="none"
className="w-full h-full"
customUrl={heroAlbum.image_url || null}
/>
<div
class="absolute right-2 top-2 z-20 flex items-center gap-1 rounded-md bg-primary/90 px-2 py-0.5 text-primary-content backdrop-blur-sm"
>
<Sparkles class="h-3 w-3" />
<span class="text-[10px] font-semibold">Featured Pick</span>
</div>
{#if heroAlbum.in_library}
<div class="absolute left-2 top-2 z-20 badge badge-success badge-sm">
<Check class="h-3.5 w-3.5" />
</div>
{:else if heroAlbum.monitored && !heroAlbum.requested}
<div class="absolute left-2 top-2 z-20 badge badge-neutral badge-sm">
<Bookmark class="h-3.5 w-3.5" />
</div>
{/if}
{#if heroAlbum.mbid && heroAlbum.in_library}
<AlbumCardOverlay
mbid={heroAlbum.mbid}
albumName={heroAlbum.name}
artistName={heroAlbum.artist_name || 'Unknown'}
coverUrl={heroAlbum.image_url || null}
size="md"
/>
{/if}
{#if !heroAlbum.mbid}
<button
type="button"
class="btn btn-ghost btn-sm btn-circle absolute bottom-1 right-1 z-20 min-h-[44px] min-w-[44px]"
title="Search album"
aria-label="Search for {heroAlbum.name}"
onclick={(e) => {
e.stopPropagation();
e.preventDefault();
handleAlbumSearch(heroAlbum);
}}
>
<Search class="h-4 w-4" />
</button>
{/if}
<div
class="pointer-events-none absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-black/70 to-transparent"
></div>
<div class="absolute inset-x-0 bottom-0 z-10 p-3">
<h3 class="text-sm font-bold text-white line-clamp-1 drop-shadow-md">
{heroAlbum.name}
</h3>
{#if heroAlbum.artist_name}
<p class="text-xs text-white/75 line-clamp-1 drop-shadow-md">
{heroAlbum.artist_name}
</p>
{/if}
</div>
</div>
</svelte:element>
{#if heroAlbum.mbid}
<div class="flex items-center justify-center gap-2 mt-2">
{#if $integrationStore.lidarr && !heroAlbum.in_library && !(heroAlbum.requested || libraryStore.isRequested(heroAlbum.mbid))}
<AlbumRequestButton
mbid={heroAlbum.mbid}
artistName={heroAlbum.artist_name ?? ''}
albumName={heroAlbum.name}
artistMbid={heroAlbum.artist_mbid ?? undefined}
/>
{/if}
<TrackPreviewButton
artist={heroAlbum.artist_name ?? ''}
track={heroAlbum.name}
ytConfigured={$integrationStore.youtube_api}
size="sm"
albumId={heroAlbum.mbid}
coverUrl={heroAlbum.image_url}
artistId={heroAlbum.artist_mbid ?? undefined}
/>
</div>
{/if}
{/if}
{#if remainingAlbums.length > 0}
<div class="min-w-0 flex-1">
<HorizontalCarousel class="-mx-4 px-4 sm:mx-0 sm:px-0 pb-2">
{#each remainingAlbums as album, i (`${album.name}-${i}`)}
{@const albumHref = albumHrefOrNull(album.mbid)}
<div class="w-32 shrink-0 sm:w-36 md:w-44">
<svelte:element
this={albumHref ? 'a' : 'div'}
href={albumHref ?? undefined}
class="card bg-base-100 w-full shadow-sm transition-all group {albumHref
? 'cursor-pointer hover:scale-105 active:scale-95 hover:shadow-[0_0_20px_oklch(from_var(--color-primary)_l_c_h/0.15)]'
: 'cursor-default opacity-90'}"
>
<figure class="aspect-square overflow-hidden relative">
<AlbumImage
mbid={album.mbid || ''}
alt={album.name}
size="md"
rounded="none"
className="w-full h-full"
customUrl={album.image_url || null}
/>
{#if album.in_library}
<div class="absolute top-2 left-2 z-20 badge badge-success badge-sm">
<Check class="w-3.5 h-3.5" />
</div>
{:else if album.monitored && !album.requested}
<div class="absolute top-2 left-2 z-20 badge badge-neutral badge-sm">
<Bookmark class="w-3.5 h-3.5" />
</div>
{/if}
{#if album.mbid && album.in_library}
<AlbumCardOverlay
mbid={album.mbid}
albumName={album.name}
artistName={album.artist_name || 'Unknown'}
coverUrl={album.image_url || null}
size="sm"
/>
{/if}
{#if !album.mbid}
<button
type="button"
class="btn btn-ghost btn-sm btn-circle absolute bottom-1 right-1 min-h-[44px] min-w-[44px]"
title="Search album"
aria-label="Search for {album.name}"
onclick={(e) => {
e.stopPropagation();
handleAlbumSearch(album);
}}
>
<Search class="w-4 h-4" />
</button>
{/if}
</figure>
<div class="card-body p-2">
<h3 class="card-title text-xs line-clamp-1">{album.name}</h3>
{#if album.artist_name}
<p class="text-xs text-base-content/50 line-clamp-1">
{album.artist_name}
</p>
{/if}
</div>
</svelte:element>
{#if album.mbid}
{@const isAlbumRequested =
album.requested || libraryStore.isRequested(album.mbid)}
<div class="flex items-center justify-center gap-1 mt-1 pb-1">
{#if $integrationStore.lidarr && !album.in_library && !isAlbumRequested}
<AlbumRequestButton
mbid={album.mbid}
artistName={album.artist_name ?? ''}
albumName={album.name}
artistMbid={album.artist_mbid ?? undefined}
/>
{/if}
<TrackPreviewButton
artist={album.artist_name ?? ''}
track={album.name}
ytConfigured={$integrationStore.youtube_api}
size="sm"
albumId={album.mbid}
coverUrl={album.image_url}
artistId={album.artist_mbid ?? undefined}
/>
</div>
{/if}
</div>
{/each}
</HorizontalCarousel>
</div>
{/if}
</div>
</div>
</section>
{/if}
@@ -1,5 +1,5 @@
<script lang="ts">
import { Music, Play, Loader2, RefreshCw } from 'lucide-svelte';
import { Disc3, Play, Loader2, RefreshCw } from 'lucide-svelte';
import { getQueueCachedData, subscribeQueueCacheChanges } from '$lib/utils/discoverQueueCache';
import type { MusicSource } from '$lib/stores/musicSource';
import { discoverQueueStatusStore, type QueueBuildStatus } from '$lib/stores/discoverQueueStatus';
@@ -53,7 +53,7 @@
style="border: 2px dashed rgb(var(--brand-discover) / 0.25); border-radius: inherit;"
></div>
<div class="card-body items-center gap-5 py-12 text-center">
<div class="text-primary opacity-70">
<div class="text-base-content/20">
{#if isBuilding && !hasCachedQueue}
<div class="flex items-end gap-0.75 h-10 w-10 justify-center pb-1">
<span class="w-1.5 bg-primary rounded-full animate-equalizer-1" style="height: 60%;"
@@ -68,7 +68,7 @@
></span>
</div>
{:else}
<Music class="h-10 w-10" strokeWidth={1.5} />
<Disc3 class="h-10 w-10" strokeWidth={1.5} />
{/if}
</div>
<div class="flex flex-col gap-1">
@@ -0,0 +1,107 @@
<script lang="ts">
import { Globe, Shuffle } from 'lucide-svelte';
interface Props {
title: string;
genres: { name: string; listen_count?: number | null; artist_count?: number | null }[];
onShuffle?: () => void;
}
let { title, genres, onShuffle }: Props = $props();
const MAX_PILLS = 15;
const genreColors = [
'from-rose-500/90 to-pink-700',
'from-violet-500/90 to-purple-700',
'from-blue-500/90 to-cyan-700',
'from-emerald-500/90 to-teal-700',
'from-amber-500/90 to-orange-700',
'from-red-500/90 to-rose-700',
'from-indigo-500/90 to-violet-700',
'from-cyan-500/90 to-blue-700',
'from-green-500/90 to-emerald-700',
'from-orange-500/90 to-amber-700'
];
function getGenreColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) | 0;
}
return genreColors[Math.abs(hash) % genreColors.length];
}
function formatCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return n.toString();
}
</script>
<section>
<div class="mb-5 flex items-center gap-2">
<Globe class="size-5 text-primary" />
<h2 class="text-lg font-bold sm:text-xl">{title}</h2>
{#if onShuffle}
<button
class="btn btn-ghost btn-sm btn-circle ml-auto tooltip tooltip-left"
data-tip="Shuffle genres"
onclick={onShuffle}
aria-label="Shuffle genres"
>
<Shuffle class="size-4" />
</button>
{/if}
</div>
<div class="flex flex-wrap gap-3">
{#each genres.slice(0, MAX_PILLS) as genre (genre.name)}
<a
href="/genre?name={encodeURIComponent(genre.name)}"
class="genre-pill group relative inline-flex items-center gap-1.5 rounded-full bg-gradient-to-r px-4 py-2 text-sm font-medium text-white shadow-lg ring-1 ring-inset ring-white/15 transition-all duration-300 motion-safe:hover:scale-105 hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-base-100 sm:px-5 sm:py-2.5 {getGenreColor(
genre.name
)}"
aria-label="{genre.name}{genre.listen_count
? ` - ${formatCount(genre.listen_count)} listens`
: ''}"
>
<span>{genre.name}</span>
{#if genre.listen_count}
<span
class="ml-0.5 rounded-full bg-black/30 px-1.5 py-0.5 text-[11px] leading-none text-white/90"
aria-hidden="true"
>
{formatCount(genre.listen_count)}
</span>
{/if}
</a>
{/each}
</div>
</section>
<style>
.genre-pill {
box-shadow:
0 4px 14px rgba(0, 0, 0, 0.3),
0 1px 3px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.genre-pill:hover {
transform: translateY(-2px) scale(1.05);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.4),
0 4px 8px rgba(0, 0, 0, 0.25),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
}
@media (prefers-reduced-motion: reduce) {
.genre-pill {
animation: none;
}
.genre-pill:hover {
transform: scale(1.05);
}
}
</style>
+62 -40
View File
@@ -6,33 +6,46 @@
HomeTrack,
HomeGenre
} from '$lib/types';
import type { Snippet } from 'svelte';
import {
ArrowRight,
X,
Check,
Bookmark,
Disc3,
Music2,
Tv,
Sparkles,
Search,
Radio,
Headphones
Search
} from 'lucide-svelte';
import { goto } from '$app/navigation';
import { albumHrefOrNull, artistHrefOrNull } from '$lib/utils/entityRoutes';
import { formatListenCount, formatListenedAt } from '$lib/utils/formatting';
import { integrationStore } from '$lib/stores/integration';
import { libraryStore } from '$lib/stores/library';
import ArtistImage from './ArtistImage.svelte';
import AlbumImage from './AlbumImage.svelte';
import AlbumCardOverlay from './AlbumCardOverlay.svelte';
import AlbumRequestButton from './AlbumRequestButton.svelte';
import HorizontalCarousel from './HorizontalCarousel.svelte';
import SourceBadge from './SourceBadge.svelte';
import TrackPreviewButton from './TrackPreviewButton.svelte';
interface Props {
section: HomeSectionType;
showConnectCard?: boolean;
headerLink?: string | null;
headerActions?: Snippet;
hideHeader?: boolean;
}
let { section, showConnectCard = true, headerLink = null }: Props = $props();
let {
section,
showConnectCard = true,
headerLink = null,
headerActions,
hideHeader = false
}: Props = $props();
function getGenreHref(genre: HomeGenre): string {
return `/genre?name=${encodeURIComponent(genre.name)}`;
@@ -73,48 +86,35 @@
</script>
<section class="mb-6 sm:mb-8">
<div class="flex items-center justify-between mb-3 sm:mb-4">
<div class="flex items-center gap-2">
{#if !hideHeader}
<div class="flex items-center justify-between mb-3 sm:mb-4">
<div class="flex items-center gap-2">
{#if headerLink}
<a
href={headerLink}
class="text-lg sm:text-xl font-bold hover:text-primary transition-colors"
>
{section.title}
</a>
{:else}
<h2 class="text-lg sm:text-xl font-bold">{section.title}</h2>
{/if}
<SourceBadge source={section.source ?? undefined} />
</div>
{#if headerActions}
{@render headerActions()}
{/if}
{#if headerLink}
<a
href={headerLink}
class="text-lg sm:text-xl font-bold hover:text-primary transition-colors"
class="text-sm text-base-content/50 hover:text-primary transition-colors flex items-center gap-1"
>
{section.title}
See all
<ArrowRight class="w-3.5 h-3.5" />
</a>
{:else}
<h2 class="text-lg sm:text-xl font-bold">{section.title}</h2>
{/if}
{#if section.source === 'lastfm'}
<span
class="badge badge-xs sm:badge-sm border-0 gap-1"
style="background-color: rgb(var(--brand-lastfm) / 0.15); color: rgb(var(--brand-lastfm));"
>
<Radio class="w-2.5 h-2.5 sm:w-3 sm:h-3" />
Last.fm
</span>
{:else if section.source === 'listenbrainz'}
<span
class="badge badge-xs sm:badge-sm border-0 gap-1"
style="background-color: rgb(var(--brand-listenbrainz) / 0.15); color: rgb(var(--brand-listenbrainz));"
>
<Headphones class="w-2.5 h-2.5 sm:w-3 sm:h-3" />
ListenBrainz
</span>
{:else if section.source}
<span class="badge badge-ghost badge-xs sm:badge-sm capitalize">{section.source}</span>
{/if}
</div>
{#if headerLink}
<a
href={headerLink}
class="text-sm text-base-content/50 hover:text-primary transition-colors flex items-center gap-1"
>
See all
<ArrowRight class="w-3.5 h-3.5" />
</a>
{/if}
</div>
{/if}
{#if section.items.length === 0 && section.fallback_message && showConnectCard}
<div class="card bg-base-200 border border-dashed border-base-300">
@@ -199,6 +199,7 @@
</div>
{:else if isAlbum(item)}
{@const albumHref = albumHrefOrNull(item.mbid)}
{@const isItemRequested = item.requested || libraryStore.isRequested(item.mbid)}
<div class="w-32 sm:w-36 md:w-44 shrink-0">
<svelte:element
this={albumHref ? 'a' : 'div'}
@@ -255,6 +256,27 @@
{/if}
</div>
</svelte:element>
{#if item.mbid}
<div class="flex items-center justify-center gap-1 mt-1 pb-1">
{#if $integrationStore.lidarr && !item.in_library && !isItemRequested}
<AlbumRequestButton
mbid={item.mbid}
artistName={item.artist_name ?? ''}
albumName={item.name}
artistMbid={item.artist_mbid ?? undefined}
/>
{/if}
<TrackPreviewButton
artist={item.artist_name ?? ''}
track={item.name}
ytConfigured={$integrationStore.youtube_api}
size="sm"
albumId={item.mbid}
coverUrl={item.image_url}
artistId={item.artist_mbid ?? undefined}
/>
</div>
{/if}
</div>
{:else if isTrack(item)}
{@const trackArtistHref = artistHrefOrNull(item.artist_mbid)}
@@ -275,7 +297,7 @@
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-2xl bg-base-200">
<Music2 class="h-6 w-6 text-base-content/40" />
<Disc3 class="h-6 w-6 text-base-content/20" />
</div>
{/if}
</figure>
@@ -1,5 +1,5 @@
<script lang="ts">
import { Music } from 'lucide-svelte';
import { Disc3 } from 'lucide-svelte';
import { nowPlayingMerged } from '$lib/stores/nowPlayingMerged.svelte';
const session = $derived(nowPlayingMerged.primarySession);
@@ -40,8 +40,8 @@
loading="lazy"
/>
{:else}
<div class="flex h-full w-full items-center justify-center bg-base-300">
<Music class="h-10 w-10 text-base-content/30" />
<div class="flex h-full w-full items-center justify-center bg-base-200">
<Disc3 class="h-10 w-10 text-base-content/20" />
</div>
{/if}
</div>
+2 -1
View File
@@ -16,6 +16,7 @@
import {
X,
Music,
Disc3,
Shuffle,
SkipBack,
AlertCircle,
@@ -126,7 +127,7 @@
<div
class="w-15 h-15 rounded-lg shadow-lg bg-base-200 flex items-center justify-center shrink-0"
>
<Music class="h-6 w-6 opacity-40" />
<Disc3 class="h-6 w-6 text-base-content/20" />
</div>
{/if}
{#if playerStore.isPlaying}
@@ -0,0 +1,517 @@
<script lang="ts">
import {
X,
Sparkles,
ListMusic,
RefreshCw,
Music2,
Users,
Tags,
Check,
ExternalLink
} from 'lucide-svelte';
import AlbumRequestButton from '$lib/components/AlbumRequestButton.svelte';
import GenreAlbumCard from '$lib/components/GenreAlbumCard.svelte';
import TrackPreviewButton from '$lib/components/TrackPreviewButton.svelte';
import { getPlaylistSuggestionsQuery } from '$lib/queries/discover/DiscoverQuery.svelte';
import { getPlaylistListQuery } from '$lib/queries/playlists/PlaylistQuery.svelte';
import { fetchAlbumTracks } from '$lib/api/albums';
import { ApiError } from '$lib/api/client';
import { formatDuration } from '$lib/utils/formatting';
import type { HomeAlbum, Track } from '$lib/types';
import { toastStore } from '$lib/stores/toast';
import { integrationStore } from '$lib/stores/integration';
import { libraryStore } from '$lib/stores/library';
import { albumHrefOrNull } from '$lib/utils/entityRoutes';
import type { MusicSource } from '$lib/stores/musicSource';
interface Props {
open: boolean;
playlistId?: string;
playlistName?: string;
source?: MusicSource;
}
let {
open = $bindable(false),
playlistId = '',
playlistName = '',
source = 'listenbrainz'
}: Props = $props();
let dialogEl: HTMLDialogElement | undefined = $state();
let selectedPlaylistId = $state('');
let selectedPlaylistName = $state('');
let suggestionCount: number = $state(15);
let ignoredIds = $state(new Set<string>());
let expandedAlbumId = $state<string | null>(null);
let expandedTracks = $state<Track[]>([]);
let expandLoading = $state(false);
let expandAbortController = $state<AbortController | null>(null);
const isPreselected = $derived(!!playlistId);
const activePlaylistId = $derived(isPreselected ? playlistId : selectedPlaylistId);
const activePlaylistName = $derived(isPreselected ? playlistName : selectedPlaylistName);
const playlistListQuery = getPlaylistListQuery(() => open && !isPreselected);
const playlists = $derived(playlistListQuery.data ?? []);
const playlistsLoading = $derived(playlistListQuery.isLoading);
const suggestionsQuery = getPlaylistSuggestionsQuery(() => ({
playlistId: activePlaylistId,
count: suggestionCount,
source,
enabled: open
}));
const suggestions = $derived(suggestionsQuery.data?.suggestions ?? null);
const profile = $derived(suggestionsQuery.data?.profile ?? null);
const isLoading = $derived(suggestionsQuery.isLoading && !!activePlaylistId);
const isError = $derived(suggestionsQuery.isError);
const queryError = $derived(suggestionsQuery.error);
const visibleAlbums = $derived.by(() => {
if (!suggestions || suggestions.type !== 'albums') return [];
return (suggestions.items as HomeAlbum[]).filter((a) => !ignoredIds.has(a.mbid ?? a.name));
});
const topGenres = $derived.by(() => {
if (!profile?.genre_distribution) return [];
const entries = Object.entries(profile.genre_distribution);
entries.sort((a, b) => b[1].length - a[1].length);
return entries.slice(0, 5).map(([genre]) => genre);
});
const errorStatus = $derived.by(() => {
if (!queryError) return 0;
if (queryError instanceof ApiError) return queryError.status;
return 0;
});
$effect(() => {
if (!dialogEl) return;
if (open) {
dialogEl.showModal();
} else {
if (dialogEl.open) dialogEl.close();
resetState();
}
});
function resetState() {
if (!isPreselected) {
selectedPlaylistId = '';
selectedPlaylistName = '';
}
ignoredIds = new Set();
expandAbortController?.abort();
expandAbortController = null;
expandedAlbumId = null;
expandedTracks = [];
}
function handleClose() {
open = false;
}
function handlePlaylistSelect(e: Event) {
const target = e.target as HTMLSelectElement;
const id = target.value;
selectedPlaylistId = id;
const found = playlists.find((p) => p.id === id);
selectedPlaylistName = found?.name ?? '';
}
function handleCountChange(e: Event) {
const target = e.target as HTMLSelectElement;
suggestionCount = parseInt(target.value, 10);
}
function ignoreAlbum(album: HomeAlbum) {
const key = album.mbid ?? album.name;
ignoredIds = new Set([...ignoredIds, key]);
if (expandedAlbumId === key) {
expandedAlbumId = null;
expandedTracks = [];
}
}
async function toggleExpand(album: HomeAlbum) {
const key = album.mbid ?? album.name;
if (expandedAlbumId === key) {
expandAbortController?.abort();
expandAbortController = null;
expandedAlbumId = null;
expandedTracks = [];
return;
}
expandAbortController?.abort();
expandedAlbumId = key;
expandedTracks = [];
if (!album.mbid) return;
const controller = new AbortController();
expandAbortController = controller;
expandLoading = true;
try {
const data = await fetchAlbumTracks(album.mbid, controller.signal);
if (expandedAlbumId === key) {
expandedTracks = data.tracks;
}
} catch (e) {
if (controller.signal.aborted) return;
toastStore.show({
message: e instanceof Error ? e.message : 'Failed to load album tracks',
type: 'error'
});
expandedTracks = [];
} finally {
if (!controller.signal.aborted) {
expandLoading = false;
}
}
}
const countOptions = [10, 15, 20, 30] as const;
const showPicker = $derived(!isPreselected && !activePlaylistId);
const showResults = $derived(!!activePlaylistId);
const allIgnored = $derived(showResults && !isLoading && !isError && visibleAlbums.length === 0);
</script>
<dialog bind:this={dialogEl} class="modal" onclose={handleClose} aria-label="Playlist Discovery">
<div
class="modal-box w-[92vw] max-w-4xl max-h-[85vh] sm:max-w-4xl max-sm:w-screen max-sm:max-w-full max-sm:max-h-screen max-sm:rounded-none flex flex-col p-0! overflow-hidden rounded-2xl bg-base-100/80 backdrop-blur-xl shadow-[0_8px_64px_oklch(var(--p)/0.12)] border border-white/10 relative"
>
<div
class="absolute top-0 inset-x-0 h-1 bg-linear-to-r from-primary/80 via-secondary/60 to-primary/80 shadow-[0_2px_12px_oklch(var(--p)/0.3)] z-10 rounded-t-2xl"
></div>
<div class="flex items-center justify-between px-6 pt-6 pb-4 shrink-0 border-b border-white/5">
<div class="flex items-center gap-2 min-w-0">
<Sparkles class="h-5 w-5 text-primary shrink-0" />
<h2 class="text-lg font-bold truncate">
{#if activePlaylistName}
Suggestions for {activePlaylistName}
{:else}
Playlist Discovery
{/if}
</h2>
</div>
<button
type="button"
class="btn btn-ghost btn-circle min-h-[44px] min-w-[44px]"
onclick={handleClose}
aria-label="Close"
>
<X class="h-5 w-5" />
</button>
</div>
<div class="flex-1 overflow-y-auto px-6 pb-6">
{#if showPicker}
<div
class="flex flex-col gap-4 py-4 rounded-xl bg-base-200/30 p-4 mt-4 border border-white/5"
>
<p class="text-sm text-base-content/60">
Pick a playlist to get album suggestions based on its contents.
</p>
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex-1">
<label class="label" for="playlist-select">
<span class="label-text font-medium">Playlist</span>
</label>
{#if playlistsLoading}
<div class="flex items-center gap-2 py-2">
<span class="loading loading-spinner loading-sm text-primary"></span>
<span class="text-sm text-base-content/60">Loading playlists…</span>
</div>
{:else if playlistListQuery.isError}
<p class="text-sm text-error/70 py-2">
{playlistListQuery.error instanceof Error
? playlistListQuery.error.message
: 'Failed to load playlists'}
</p>
{:else if playlists.length === 0}
<p class="text-sm text-base-content/50 py-2">
No playlists found. Create one first.
</p>
{:else}
<select
id="playlist-select"
class="select select-bordered w-full"
value=""
onchange={handlePlaylistSelect}
>
<option value="" disabled>Select a playlist</option>
{#each playlists as pl (pl.id)}
<option value={pl.id}>
{pl.name} ({pl.track_count} track{pl.track_count === 1 ? '' : 's'})
</option>
{/each}
</select>
{/if}
</div>
<div class="w-full sm:w-28">
<label class="label" for="count-select">
<span class="label-text font-medium">Count</span>
</label>
<select
id="count-select"
class="select select-bordered w-full"
value={String(suggestionCount)}
onchange={handleCountChange}
>
{#each countOptions as c (c)}
<option value={String(c)}>{c}</option>
{/each}
</select>
</div>
</div>
</div>
{/if}
{#if showResults}
<div aria-live="polite">
{#if !showPicker}
<div class="flex items-center gap-2 mb-4 mt-4">
<label class="text-sm text-base-content/60" for="count-select-inline">Results:</label>
<select
id="count-select-inline"
class="select select-bordered select-sm w-20"
value={String(suggestionCount)}
onchange={handleCountChange}
>
{#each countOptions as c (c)}
<option value={String(c)}>{c}</option>
{/each}
</select>
</div>
{/if}
{#if isLoading}
<div class="flex flex-col gap-5 py-4">
<div
class="rounded-xl bg-gradient-to-r from-primary/10 via-base-200/60 to-secondary/10 p-4 border border-white/10 backdrop-blur-sm"
>
<div class="flex flex-wrap items-center gap-4">
<div class="skeleton h-4 w-24 rounded"></div>
<div class="skeleton h-4 w-20 rounded"></div>
<div class="flex gap-1">
<div class="skeleton h-5 w-12 rounded-full"></div>
<div class="skeleton h-5 w-16 rounded-full"></div>
<div class="skeleton h-5 w-10 rounded-full"></div>
</div>
</div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{#each Array(8) as _, i (i)}
<div class="flex flex-col gap-2">
<div class="skeleton aspect-square w-full rounded-2xl"></div>
<div class="skeleton h-4 w-3/4 rounded"></div>
<div class="skeleton h-3 w-1/2 rounded"></div>
</div>
{/each}
</div>
</div>
{:else if isError}
<div class="flex flex-col items-center justify-center py-12 px-8">
{#if errorStatus === 422}
<ListMusic class="h-10 w-10 text-warning mb-3" />
<p class="text-center text-base-content/70 max-w-md">
This playlist doesn't have enough artist data for discovery. Add more tracks with
known artists and try again.
</p>
{:else if errorStatus === 404}
<p class="text-center text-base-content/70">Playlist not found.</p>
{:else}
<p class="text-center text-base-content/70 mb-4">
Couldn't load suggestions. Try again.
</p>
{/if}
<button
type="button"
class="btn btn-primary btn-sm mt-4"
onclick={() => suggestionsQuery.refetch()}
>
<RefreshCw class="h-3.5 w-3.5" />
Try Again
</button>
</div>
{:else if allIgnored}
<div class="flex flex-col items-center justify-center py-12 px-8">
<p class="text-center text-base-content/60">
No more suggestions left. Add more tracks or more varied artists for better results.
</p>
</div>
{:else if suggestions && visibleAlbums.length === 0}
<div class="flex flex-col items-center justify-center py-12 px-8">
<p class="text-center text-base-content/60">
{suggestions.fallback_message ??
'No suggestions yet - try adding more songs or more varied artists to generate recommendations.'}
</p>
</div>
{:else}
{#if profile}
<div
class="rounded-xl bg-gradient-to-r from-primary/10 via-base-200/60 to-secondary/10 p-4 mb-5 border border-white/10 backdrop-blur-sm"
>
<div class="flex flex-wrap items-center gap-4 text-sm">
<span class="inline-flex items-center gap-1.5 text-base-content/70">
<Music2 class="h-3.5 w-3.5" />
{profile.track_count} track{profile.track_count === 1 ? '' : 's'}
</span>
<span class="inline-flex items-center gap-1.5 text-base-content/70">
<Users class="h-3.5 w-3.5" />
{profile.artist_mbids.length} artist{profile.artist_mbids.length === 1
? ''
: 's'}
</span>
{#if topGenres.length > 0}
<span class="inline-flex items-center gap-1.5 text-base-content/70">
<Tags class="h-3.5 w-3.5 shrink-0" />
</span>
<div class="flex flex-wrap gap-1">
{#each topGenres as genre (genre)}
<span
class="inline-flex items-center rounded-full bg-gradient-to-r from-primary/20 to-secondary/20 px-2.5 py-0.5 text-xs font-medium text-primary border border-primary/20"
>{genre}</span
>
{/each}
</div>
{/if}
</div>
</div>
{/if}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{#each visibleAlbums as album (album.mbid ?? album.name)}
{@const albumKey = album.mbid ?? album.name}
{@const isExpanded = expandedAlbumId === albumKey}
<div
class="flex flex-col overflow-hidden"
class:col-span-2={isExpanded}
class:sm:col-span-3={isExpanded}
class:md:col-span-4={isExpanded}
>
<div class="flex flex-col sm:flex-row gap-2">
<div class="w-full" class:sm:w-40={isExpanded} class:sm:shrink-0={isExpanded}>
<GenreAlbumCard {album} onclick={() => toggleExpand(album)} />
</div>
{#if isExpanded}
<div
class="flex-1 min-w-0 rounded-lg bg-base-200/30 border border-white/5 p-2 mt-2 sm:mt-0"
>
{#if expandLoading}
<div class="flex flex-col gap-1.5 py-1">
{#each Array(4) as _, i (i)}
<div class="skeleton h-6 w-full rounded"></div>
{/each}
</div>
{:else if expandedTracks.length > 0}
<div class="max-h-48 overflow-y-auto">
<table class="table table-xs w-full">
<tbody>
{#each expandedTracks as track (track.position)}
<tr class="hover:bg-base-200/50">
<td class="opacity-70 w-6 text-right pr-2 text-base-content/40"
>{track.position}</td
>
<td class="truncate max-w-0">{track.title}</td>
<td class="text-base-content/40 text-right w-12 shrink-0"
>{formatDuration(track.length)}</td
>
<td class="w-8 px-0">
<TrackPreviewButton
artist={album.artist_name ?? ''}
track={track.title}
ytConfigured={$integrationStore.youtube_api}
size="sm"
albumId={album.mbid ?? `track-${track.position}`}
coverUrl={album.image_url}
artistId={album.artist_mbid ?? undefined}
/>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="text-xs text-base-content/50 py-2">
No track listing available.
</p>
{/if}
</div>
{/if}
</div>
<div class="flex items-center justify-center gap-1 mt-2">
{#if album.mbid}
{@const albumLink = albumHrefOrNull(album.mbid)}
{@const isAlbumRequested =
album.requested || libraryStore.isRequested(album.mbid)}
{#if albumLink}
<div class="tooltip tooltip-bottom" data-tip="Go to album">
<a
href={albumLink}
class="btn btn-circle btn-ghost btn-sm border border-white/10 hover:border-primary/30 hover:text-primary min-h-[36px] min-w-[36px]"
aria-label="Go to {album.name}"
>
<ExternalLink class="h-3.5 w-3.5" />
</a>
</div>
{/if}
{#if $integrationStore.lidarr && !album.in_library && !isAlbumRequested}
<div class="tooltip tooltip-bottom" data-tip="Request album">
<AlbumRequestButton
mbid={album.mbid}
artistName={album.artist_name ?? ''}
albumName={album.name}
artistMbid={album.artist_mbid ?? undefined}
/>
</div>
{:else if album.in_library}
<div class="tooltip tooltip-bottom" data-tip="In library">
<span
class="btn btn-circle btn-ghost btn-sm border border-success/30 text-success min-h-[36px] min-w-[36px] cursor-default"
>
<Check class="h-3.5 w-3.5" />
</span>
</div>
{/if}
<TrackPreviewButton
artist={album.artist_name ?? ''}
track={album.name}
ytConfigured={$integrationStore.youtube_api}
size="sm"
albumId={album.mbid}
coverUrl={album.image_url}
artistId={album.artist_mbid ?? undefined}
/>
{/if}
<button
type="button"
class="btn btn-circle btn-ghost btn-sm border border-white/10 hover:border-error/30 hover:text-error min-h-[36px] min-w-[36px]"
onclick={() => ignoreAlbum(album)}
title="Ignore suggestion"
aria-label="Ignore suggestion"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="submit">close</button>
</form>
</dialog>
@@ -1,7 +1,7 @@
<script lang="ts">
import type { SourcePlaylistSummary } from '$lib/types';
import type { Snippet } from 'svelte';
import { ListMusic, ChevronRight, CheckCircle2, ArrowRight } from 'lucide-svelte';
import { Disc3, ChevronRight, CheckCircle2, ArrowRight } from 'lucide-svelte';
import { reveal } from '$lib/actions/reveal';
interface Props {
@@ -70,13 +70,13 @@
{#each [0, 1, 2] as i (i)}
{@const angle = [-6, 0, 6][i]}
<div
class="absolute flex h-20 w-20 items-center justify-center overflow-hidden rounded-xl border-2 border-base-100 bg-gradient-to-br from-primary/15 to-secondary/15 shadow-md transition-all duration-500"
class="absolute flex h-20 w-20 items-center justify-center overflow-hidden rounded-xl border-2 border-base-100 bg-base-200 shadow-md transition-all duration-500"
style="
transform: rotate({isHovered ? angle * 1.4 : angle}deg);
z-index: {i};
"
>
<ListMusic class="h-8 w-8 text-base-content/20" />
<Disc3 class="h-8 w-8 text-base-content/20" />
</div>
{/each}
{/if}
@@ -1,5 +1,5 @@
<script lang="ts">
import { Music } from 'lucide-svelte';
import { Disc3 } from 'lucide-svelte';
interface Props {
coverUrls?: string[];
@@ -25,10 +25,8 @@
</script>
{#snippet gridFallback()}
<div
class="bg-linear-to-br from-base-300 to-base-200 w-full h-full flex items-center justify-center"
>
<Music class="w-1/3 h-1/3 text-base-content/30" />
<div class="bg-base-200 w-full h-full flex items-center justify-center">
<Disc3 class="w-1/3 h-1/3 text-base-content/20" />
</div>
{/snippet}
@@ -91,10 +89,8 @@
</div>
{:else if urls.length === 1}
{#if imageErrors[0]}
<div
class="bg-linear-to-br from-base-300 to-base-200 w-full h-full flex items-center justify-center"
>
<Music class="w-1/3 h-1/3 text-base-content/30" />
<div class="bg-base-200 w-full h-full flex items-center justify-center">
<Disc3 class="w-1/3 h-1/3 text-base-content/20" />
</div>
{:else}
<img
@@ -106,10 +102,8 @@
/>
{/if}
{:else}
<div
class="bg-linear-to-br from-base-300 to-base-200 w-full h-full flex items-center justify-center"
>
<Music class="w-1/3 h-1/3 text-base-content/30" />
<div class="bg-base-200 w-full h-full flex items-center justify-center">
<Disc3 class="w-1/3 h-1/3 text-base-content/20" />
</div>
{/if}
</div>
@@ -4,7 +4,7 @@
import { playerStore } from '$lib/stores/player.svelte';
import { playbackToast } from '$lib/stores/playbackToast.svelte';
import { getCoverUrl } from '$lib/utils/errorHandling';
import { X, GripVertical, ListMusic, Music, Shuffle, Trash2 } from 'lucide-svelte';
import { X, GripVertical, ListMusic, Disc3, Shuffle, Trash2 } from 'lucide-svelte';
import JellyfinIcon from '$lib/components/JellyfinIcon.svelte';
import LocalFilesIcon from '$lib/components/LocalFilesIcon.svelte';
import NavidromeIcon from '$lib/components/NavidromeIcon.svelte';
@@ -294,8 +294,8 @@
{#if coverUrl}
<img src={coverUrl} alt={item.albumName} class="w-full h-full object-cover" />
{:else}
<div class="w-full h-full bg-base-300 flex items-center justify-center">
<Music class="h-4 w-4 opacity-30" />
<div class="w-full h-full bg-base-200 flex items-center justify-center">
<Disc3 class="h-4 w-4 text-base-content/20" />
</div>
{/if}
</div>
@@ -0,0 +1,217 @@
<script lang="ts">
import type { HomeSection as HomeSectionType, HomeAlbum } from '$lib/types';
import type { MusicSource } from '$lib/stores/musicSource';
import HomeSection from '$lib/components/HomeSection.svelte';
import AlbumImage from '$lib/components/AlbumImage.svelte';
import { getRadioQuery } from '$lib/queries/discover/DiscoverQuery.svelte';
import { invalidateQueriesWithPersister } from '$lib/queries/QueryClient';
import { DiscoverQueryKeyFactory } from '$lib/queries/discover/DiscoverQueryKeyFactory';
import { ChevronDown, Disc3, Radio, RefreshCw } from 'lucide-svelte';
import { tilt } from '$lib/actions/tilt';
import { slide } from 'svelte/transition';
interface Props {
seedType: string;
seedId: string;
source: MusicSource;
initialSection?: HomeSectionType | null;
}
let { seedType, seedId, source, initialSection = null }: Props = $props();
const radioQuery = getRadioQuery(() => ({ seedType, seedId, source }));
const section = $derived(radioQuery.data ?? initialSection);
let expanded = $state(false);
const albumItems = $derived(
section ? section.items.filter((item): item is HomeAlbum => section.type === 'albums') : []
);
const featuredAlbum = $derived(albumItems[0] ?? null);
const albumCount = $derived(albumItems.length);
function toggleExpanded() {
expanded = !expanded;
}
async function handleRefresh() {
await invalidateQueriesWithPersister({
queryKey: DiscoverQueryKeyFactory.radio(seedType, seedId, source)
});
}
</script>
{#if section}
<div class="w-full">
<div style="perspective: 1200px;">
<button
use:tilt={{ shadowColorVar: 'var(--s)' }}
type="button"
class="group relative w-full overflow-hidden rounded-2xl border transition-all cursor-pointer text-left
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-secondary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-base-100
active:scale-[0.98]
{expanded ? 'border-secondary/30' : 'border-secondary/15'}"
style="
transform-style: preserve-3d;
transform: var(--tilt-transform, rotateX(0) rotateY(0));
box-shadow: var(--tilt-shadow);
transition: transform 0.5s var(--ease-spring), box-shadow 0.5s var(--ease-spring), border-color 0.3s ease;
"
onclick={toggleExpanded}
aria-expanded={expanded}
aria-label="{section.title} radio - {section.items.length} albums. {expanded
? 'Collapse'
: 'Expand'} to browse."
>
<div class="absolute inset-0" style="height: 140px;">
{#if featuredAlbum}
<AlbumImage
mbid={featuredAlbum.mbid || ''}
alt={featuredAlbum.name}
size="md"
rounded="none"
className="w-full h-full object-cover"
customUrl={featuredAlbum.image_url || null}
/>
{:else}
<div class="flex h-full w-full items-center justify-center bg-base-200">
<Disc3 class="h-8 w-8 text-base-content/20" />
</div>
{/if}
</div>
<div
class="absolute inset-0 bg-gradient-to-t from-black/75 via-black/45 to-black/25"
style="height: 140px;"
></div>
<div
class="radio-waves pointer-events-none absolute inset-0 overflow-hidden"
style="height: 140px;"
aria-hidden="true"
>
<div class="wave wave-1"></div>
<div class="wave wave-2"></div>
<div class="wave wave-3"></div>
</div>
<div
class="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-br from-secondary/[0.06] to-transparent"
style="height: 140px;"
></div>
<div
class="pointer-events-none absolute inset-0 rounded-2xl"
style="background: var(--tilt-specular-bg, transparent); height: 140px;"
></div>
<div class="absolute right-3 top-3 opacity-20" style="height: auto;">
<Radio class="h-6 w-6 text-white" />
</div>
<div class="relative flex items-end justify-between p-4" style="height: 140px;">
<div class="mr-3 flex min-w-0 flex-1 flex-col gap-1.5">
<h3
class="line-clamp-2 text-sm font-bold leading-tight text-white drop-shadow-lg sm:text-base"
>
{section.title}
</h3>
<div class="flex items-center gap-2">
<span
class="inline-flex items-center gap-1 rounded-full bg-black/40 px-2 py-0.5 text-xs text-white/70 backdrop-blur-sm"
>
<Radio class="h-3 w-3" />
{albumCount} album{albumCount !== 1 ? 's' : ''}
</span>
</div>
</div>
<div
class="flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-black/40 backdrop-blur-sm transition-all
group-hover:border-secondary/30 group-hover:bg-secondary/20"
>
<ChevronDown
class="h-4 w-4 text-white/80 transition-transform duration-300
{expanded ? 'rotate-180' : ''}"
/>
</div>
</div>
</button>
</div>
</div>
{#if expanded}
<div transition:slide={{ duration: 300 }} class="col-span-full overflow-hidden">
<div class="relative pt-3">
<div class="absolute right-0 top-3 z-10">
<button
class="btn btn-ghost btn-sm gap-1"
onclick={handleRefresh}
disabled={radioQuery.isFetching}
>
<RefreshCw class="h-3.5 w-3.5 {radioQuery.isFetching ? 'animate-spin' : ''}" />
Refresh
</button>
</div>
<HomeSection {section} showConnectCard={false} />
</div>
</div>
{/if}
{/if}
<style>
/* Radio wave concentric arcs emanating from top-right */
.radio-waves {
--wave-color: oklch(var(--s) / 0.12);
}
.wave {
position: absolute;
top: -20px;
right: -20px;
border-radius: 50%;
border: 1.5px solid var(--wave-color);
opacity: 0;
animation: radio-pulse 3s ease-out infinite;
}
.wave-1 {
width: 60px;
height: 60px;
animation-delay: 0s;
}
.wave-2 {
width: 100px;
height: 100px;
animation-delay: 1s;
}
.wave-3 {
width: 140px;
height: 140px;
animation-delay: 2s;
}
@keyframes radio-pulse {
0% {
opacity: 0;
transform: scale(0.5);
}
30% {
opacity: 1;
}
100% {
opacity: 0;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.wave {
animation: none;
opacity: 0.5;
transform: scale(1);
}
}
</style>
@@ -0,0 +1,82 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('$lib/queries/QueryClient', () => ({
invalidateQueriesWithPersister: vi.fn().mockResolvedValue(undefined)
}));
import { DiscoverQueryKeyFactory } from '$lib/queries/discover/DiscoverQueryKeyFactory';
import { invalidateQueriesWithPersister } from '$lib/queries/QueryClient';
const mockInvalidate = vi.mocked(invalidateQueriesWithPersister);
describe('DiscoverQueryKeyFactory.radio', () => {
it('returns the expected key shape for artist seed', () => {
const key = DiscoverQueryKeyFactory.radio('artist', 'test-mbid', 'listenbrainz');
expect(key).toEqual(['discover', 'radio', 'artist', 'test-mbid', { source: 'listenbrainz' }]);
});
it('returns the expected key shape for album seed', () => {
const key = DiscoverQueryKeyFactory.radio('album', 'album-mbid', 'lastfm');
expect(key).toEqual(['discover', 'radio', 'album', 'album-mbid', { source: 'lastfm' }]);
});
it('generates different keys for different seed types', () => {
const artistKey = DiscoverQueryKeyFactory.radio('artist', 'test-mbid', 'listenbrainz');
const albumKey = DiscoverQueryKeyFactory.radio('album', 'test-mbid', 'listenbrainz');
expect(artistKey).not.toEqual(albumKey);
});
it('generates different keys for different seed IDs', () => {
const key1 = DiscoverQueryKeyFactory.radio('artist', 'mbid-1', 'listenbrainz');
const key2 = DiscoverQueryKeyFactory.radio('artist', 'mbid-2', 'listenbrainz');
expect(key1).not.toEqual(key2);
});
it('generates different keys for different sources', () => {
const key1 = DiscoverQueryKeyFactory.radio('artist', 'test-mbid', 'listenbrainz');
const key2 = DiscoverQueryKeyFactory.radio('artist', 'test-mbid', 'lastfm');
expect(key1).not.toEqual(key2);
});
it('starts with the discover prefix', () => {
const key = DiscoverQueryKeyFactory.radio('artist', 'test-mbid', 'listenbrainz');
expect(key[0]).toBe('discover');
expect(key[1]).toBe('radio');
});
});
describe('RadioSection refresh invalidation contract', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('refresh calls invalidateQueriesWithPersister with correct radio query key', async () => {
const seedType = 'artist';
const seedId = 'abc-123';
const source = 'listenbrainz' as const;
// Reproduce RadioSection.svelte handleRefresh() logic
await invalidateQueriesWithPersister({
queryKey: DiscoverQueryKeyFactory.radio(seedType, seedId, source)
});
expect(mockInvalidate).toHaveBeenCalledOnce();
expect(mockInvalidate).toHaveBeenCalledWith({
queryKey: ['discover', 'radio', 'artist', 'abc-123', { source: 'listenbrainz' }]
});
});
it('refresh uses distinct keys for different seeds', async () => {
const source = 'listenbrainz' as const;
await invalidateQueriesWithPersister({
queryKey: DiscoverQueryKeyFactory.radio('artist', 'seed-1', source)
});
await invalidateQueriesWithPersister({
queryKey: DiscoverQueryKeyFactory.radio('album', 'seed-2', source)
});
expect(mockInvalidate).toHaveBeenCalledTimes(2);
expect(mockInvalidate.mock.calls[0][0]).not.toEqual(mockInvalidate.mock.calls[1][0]);
});
});
@@ -1,6 +1,6 @@
<script lang="ts">
import { getApiUrl } from '$lib/api/api-utils';
import { Search } from 'lucide-svelte';
import { Disc3, Search } from 'lucide-svelte';
import type { SuggestResult } from '$lib/types';
import { API } from '$lib/constants';
import { isAbortError } from '$lib/utils/errorHandling';
@@ -29,6 +29,7 @@
const listboxId = $derived(`${id}-listbox`);
let suggestions = $state<SuggestResult[]>([]);
let imageErrors = $state<Record<string, boolean>>({});
let loading = $state(false);
let showDropdown = $state(false);
let activeIndex = $state(-1);
@@ -234,15 +235,19 @@
tabindex="-1"
>
<div class="avatar avatar-placeholder">
<div class="w-10 h-10 rounded bg-base-300">
<img
src={coverUrl(result)}
alt={result.title}
onerror={(e: Event) => {
const target = e.currentTarget as HTMLImageElement;
target.style.display = 'none';
}}
/>
<div class="w-10 h-10 rounded bg-base-200 flex items-center justify-center">
{#if imageErrors[result.musicbrainz_id]}
<Disc3 class="h-5 w-5 text-base-content/20" />
{:else}
<img
src={coverUrl(result)}
alt={result.title}
class="w-full h-full object-cover rounded"
onerror={() => {
imageErrors[result.musicbrainz_id] = true;
}}
/>
{/if}
</div>
</div>
<div class="flex-1 min-w-0">
@@ -3,7 +3,7 @@
import { artistHref, albumHref } from '$lib/utils/entityRoutes';
import HeroBackdrop from './HeroBackdrop.svelte';
import ArtistImage from './ArtistImage.svelte';
import { ArrowRight } from 'lucide-svelte';
import { ArrowRight, Disc3 } from 'lucide-svelte';
interface Props {
artist?: Artist | null;
@@ -78,23 +78,8 @@
{#if album.cover_url}
<img src={album.cover_url} alt={album.title} class="w-full h-full object-cover" />
{:else}
<div
class="w-full h-full bg-base-300 flex items-center justify-center text-base-content/30"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
<div class="w-full h-full bg-base-200 flex items-center justify-center">
<Disc3 class="h-8 w-8 text-base-content/20" />
</div>
{/if}
</div>
@@ -1,7 +1,7 @@
<script lang="ts">
import ArtistImage from './ArtistImage.svelte';
import ArtistCardDownloadButton from './ArtistCardDownloadButton.svelte';
import { Music2 } from 'lucide-svelte';
import { Users } from 'lucide-svelte';
interface Props {
name: string;
@@ -35,9 +35,9 @@
/>
{:else}
<div
class="flex h-full w-full items-center justify-center rounded-full bg-base-200 text-base-content/30"
class="flex h-full w-full items-center justify-center rounded-full bg-base-200 text-base-content/20"
>
<Music2 class="h-12 w-12" />
<Users class="h-12 w-12" />
</div>
{/if}
</figure>
@@ -62,9 +62,9 @@
/>
{:else}
<div
class="flex h-full w-full items-center justify-center rounded-full bg-base-200 text-base-content/30"
class="flex h-full w-full items-center justify-center rounded-full bg-base-200 text-base-content/20"
>
<Music2 class="h-12 w-12" />
<Users class="h-12 w-12" />
</div>
{/if}
</figure>
@@ -0,0 +1,29 @@
<script lang="ts">
import { Radio, Headphones } from 'lucide-svelte';
interface Props {
source?: string;
}
let { source }: Props = $props();
</script>
{#if source === 'lastfm'}
<span
class="badge badge-xs sm:badge-sm border-0 gap-1"
style="background-color: rgb(var(--brand-lastfm) / 0.15); color: rgb(var(--brand-lastfm));"
>
<Radio class="w-2.5 h-2.5 sm:w-3 sm:h-3" />
Last.fm
</span>
{:else if source === 'listenbrainz'}
<span
class="badge badge-xs sm:badge-sm border-0 gap-1"
style="background-color: rgb(var(--brand-listenbrainz) / 0.15); color: rgb(var(--brand-listenbrainz));"
>
<Headphones class="w-2.5 h-2.5 sm:w-3 sm:h-3" />
ListenBrainz
</span>
{:else if source}
<span class="badge badge-ghost badge-xs sm:badge-sm capitalize">{source}</span>
{/if}
@@ -1,7 +1,7 @@
<script lang="ts">
import type { SourcePlaylistSummary } from '$lib/types';
import { formatTotalDurationSec } from '$lib/utils/formatting';
import { ListMusic } from 'lucide-svelte';
import { Disc3 } from 'lucide-svelte';
interface Props {
playlist: SourcePlaylistSummary;
@@ -44,10 +44,8 @@
}}
/>
{:else}
<div
class="w-full h-full bg-gradient-to-br from-primary/20 to-secondary/20 flex items-center justify-center"
>
<ListMusic class="w-14 h-14 text-base-content/30" />
<div class="w-full h-full bg-base-200 flex items-center justify-center">
<Disc3 class="w-14 h-14 text-base-content/20" />
</div>
{/if}
</div>
@@ -3,7 +3,7 @@
import BackButton from '$lib/components/BackButton.svelte';
import { toastStore } from '$lib/stores/toast';
import { formatTotalDurationSec } from '$lib/utils/formatting';
import { Download, ListMusic } from 'lucide-svelte';
import { Disc3, Download } from 'lucide-svelte';
import type { SourcePlaylistDetail, SourceImportResult } from '$lib/types';
import type { Snippet } from 'svelte';
@@ -79,7 +79,7 @@
<img src={detail.cover_url} alt={detail.name} class="w-full h-full object-cover" />
{:else}
<div class="w-full h-full bg-base-200 flex items-center justify-center">
<ListMusic class="w-16 h-16 text-base-content/20" />
<Disc3 class="w-16 h-16 text-base-content/20" />
</div>
{/if}
</div>
@@ -1,7 +1,7 @@
<script lang="ts">
import { albumHref } from '$lib/utils/entityRoutes';
import { onMount } from 'svelte';
import { Music2, Download } from 'lucide-svelte';
import { Disc3, Download } from 'lucide-svelte';
import type { TopAlbum } from '$lib/types';
import { colors } from '$lib/colors';
import { libraryStore } from '$lib/stores/library';
@@ -208,8 +208,8 @@
{#if source === 'lastfm'}
<LastFmPlaceholder />
{:else}
<div class="w-12 h-12 shrink-0 bg-base-300 rounded flex items-center justify-center">
<Music2 class="w-6 h-6 opacity-50" />
<div class="w-12 h-12 shrink-0 bg-base-200 rounded flex items-center justify-center">
<Disc3 class="w-6 h-6 text-base-content/20" />
</div>
{/if}
@@ -91,7 +91,6 @@
showError();
return;
}
// Transient failure — retry once
await new Promise((r) => setTimeout(r, 1500));
const retry = await doSearch();
if (!retry.success) showError();
@@ -120,15 +119,14 @@
!ytConfigured
? 'Set up YouTube API key in Settings to preview'
: errorFlash
? 'Preview failed tap to retry'
? 'Preview failed - tap to retry'
: cached === true
? 'Play · cached, no quota used'
: 'Preview · uses 1 YouTube lookup'
);
const btnClasses = $derived.by(() => {
const base =
size === 'sm' ? 'btn btn-circle btn-ghost btn-xs' : 'btn btn-circle btn-ghost btn-sm';
const base = size === 'sm' ? 'btn btn-circle btn-ghost btn-sm' : 'btn btn-circle btn-ghost';
if (!ytConfigured) return `${base} text-base-content/20 cursor-not-allowed`;
if (searching) return `${base} text-primary`;
if (errorFlash) return `${base} text-error`;
@@ -136,10 +134,10 @@
return `${base} text-base-content/50 hover:text-primary`;
});
const iconSize = $derived(size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4');
const iconSize = $derived(size === 'sm' ? 'h-4 w-4' : 'h-5 w-5');
</script>
<div class="tooltip tooltip-bottom" data-tip={tooltip}>
<div class="tooltip tooltip-top z-50" data-tip={tooltip}>
<button
type="button"
class={btnClasses}
+3 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { albumHref } from '$lib/utils/entityRoutes';
import { Play, Music2 } from 'lucide-svelte';
import { Play, Disc3 } from 'lucide-svelte';
import type { TopSong, ResolvedTrack } from '$lib/types';
import AlbumImage from './AlbumImage.svelte';
import LastFmPlaceholder from './LastFmPlaceholder.svelte';
@@ -131,8 +131,8 @@
{#if isLastfmNoAlbum}
<LastFmPlaceholder />
{:else}
<div class="w-12 h-12 shrink-0 bg-base-300 rounded flex items-center justify-center">
<Music2 class="w-6 h-6 opacity-50" />
<div class="w-12 h-12 shrink-0 bg-base-200 rounded flex items-center justify-center">
<Disc3 class="w-6 h-6 text-base-content/20" />
</div>
{/if}
@@ -1,14 +1,15 @@
<script lang="ts">
import type { HomeSettings } from '$lib/types';
import { createSettingsForm } from '$lib/utils/settingsForm.svelte';
import { removeDiscoverCachedData } from '$lib/utils/discoverCache';
import { invalidateQueriesWithPersister } from '$lib/queries/QueryClient';
import { DiscoverQueryKeyFactory } from '$lib/queries/discover/DiscoverQueryKeyFactory';
import { onMount, onDestroy } from 'svelte';
const form = createSettingsForm<HomeSettings>({
loadEndpoint: '/api/v1/settings/home',
saveEndpoint: '/api/v1/settings/home',
afterSave: async () => {
removeDiscoverCachedData();
await invalidateQueriesWithPersister({ queryKey: DiscoverQueryKeyFactory.prefix });
}
});
@@ -30,7 +31,7 @@
<div class="card bg-base-200">
<div class="card-body">
<h2 class="card-title text-2xl">Discover</h2>
<p class="text-base-content/70 mb-4">Choose what shows up on the Discover page.</p>
<p class="text-base-content/70 mb-4">Pick what appears on the Discover page.</p>
{#if form.loading}
<div class="flex justify-center items-center py-12">
@@ -48,7 +49,7 @@
<div>
<span class="label-text font-medium">Show Globally Trending</span>
<p class="text-xs text-base-content/50">
Shows trending artists from around the world in Charts & Activity.
Shows trending artists worldwide in Charts & Activity.
</p>
</div>
</label>
@@ -69,8 +69,8 @@
<h4 class="font-medium text-sm text-base-content/70 mb-3">Discover Queue</h4>
<div class="alert alert-info alert-soft mb-4">
<span class="text-sm"
>These settings control how Discover Queue is prepared in the background so it is ready when you
open Discover.</span
>Controls how the Discover Queue is built in the background so it's ready when you open
Discover.</span
>
</div>
<h4 class="font-medium text-sm text-base-content/70 mb-3">Background Generation</h4>
@@ -169,3 +169,23 @@
max={50}
/>
</div>
<div class="divider my-4"></div>
<h4 class="font-medium text-sm text-base-content/70 mb-3">Discover Picks</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
<SettingsNumberField
label="Discover Picks Count"
description="How many albums appear in Discover Picks (default: 12)"
bind:value={data.discover_picks_count}
min={4}
max={30}
step={1}
/>
<SettingsNumberField
label="Genre Affinity Weight"
description="How strongly picks are biased towards your genres (0.0 = random, 1.0 = genre-only, default: 0.7)"
bind:value={data.discover_picks_genre_affinity_weight}
min={0}
max={1}
step={0.1}
/>
</div>
@@ -68,4 +68,6 @@ export interface AdvancedSettingsForm {
audiodb_prewarm_concurrency: number;
audiodb_prewarm_delay: number;
artist_discovery_precache_concurrency: number;
discover_picks_genre_affinity_weight: number;
discover_picks_count: number;
}
+5 -8
View File
@@ -5,7 +5,6 @@ export const CACHE_KEY_GROUPS = {
LIBRARY_MBIDS: 'musicseerr_library_mbids',
RECENTLY_ADDED: 'musicseerr_recently_added',
HOME_CACHE: 'musicseerr_home_cache',
DISCOVER_CACHE: 'musicseerr_discover_cache',
DISCOVER_QUEUE: 'musicseerr_discover_queue',
SEARCH: 'musicseerr_search_cache'
},
@@ -131,12 +130,6 @@ export const IMAGE_PIXEL_SAMPLE_STEP = 16;
export const ALPHA_THRESHOLD = 128;
export const PLACEHOLDER_COLORS = {
DARK: '#0d120a',
MEDIUM: '#161d12',
LIGHT: '#1F271B'
} as const;
export const STATUS_COLORS = {
REQUESTED: '#F59E0B',
MONITORED: '#6B7280'
@@ -191,7 +184,8 @@ export const API = {
},
home: (source: string) => `/api/v1/home?source=${encodeURIComponent(source)}`,
homeIntegrationStatus: () => '/api/v1/home/integration-status',
discover: () => '/api/v1/discover',
discover: (source?: string) =>
source ? `/api/v1/discover?source=${encodeURIComponent(source)}` : '/api/v1/discover',
discoverRefresh: () => '/api/v1/discover/refresh',
discoverQueue: (source?: string) => `/api/v1/discover/queue${source ? `?source=${source}` : ''}`,
discoverQueueStatus: (source?: string) =>
@@ -207,6 +201,9 @@ export const API = {
`/api/v1/discover/queue/youtube-track-search?artist=${encodeURIComponent(artist)}&track=${encodeURIComponent(track)}`,
discoverQueueYoutubeQuota: () => '/api/v1/discover/queue/youtube-quota',
discoverQueueYoutubeCacheCheck: () => '/api/v1/discover/queue/youtube-cache-check',
discoverRadio: () => '/api/v1/discover/radio',
discoverPlaylistSuggestions: () => '/api/v1/discover/playlist-suggestions',
discoverGenreDetail: (tag: string) => `/api/v1/discover/genres/${encodeURIComponent(tag)}`,
youtube: {
generate: () => '/api/v1/youtube/generate',
link: (albumId: string) => `/api/v1/youtube/link/${albumId}`,
@@ -0,0 +1,220 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { DiscoverQueryKeyFactory } from './DiscoverQueryKeyFactory';
vi.mock('@tanstack/svelte-query', () => ({
createQuery: vi.fn((factory: () => Record<string, unknown>) => {
const opts = factory();
return opts;
}),
queryOptions: vi.fn((opts: Record<string, unknown>) => opts)
}));
vi.mock('$lib/api/client', async () => {
class ApiErrorMock extends Error {
readonly status: number;
readonly code: string;
readonly details: unknown;
constructor(status: number, message: string, code = '', details: unknown = null) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.details = details;
}
}
return {
ApiError: ApiErrorMock,
api: {
global: {
get: vi.fn(),
post: vi.fn()
}
}
};
});
import { api } from '$lib/api/client';
import { createQuery } from '@tanstack/svelte-query';
const mockPost = vi.mocked(api.global.post);
const mockCreateQuery = vi.mocked(createQuery);
beforeEach(() => {
vi.clearAllMocks();
});
describe('DiscoverQueryKeyFactory', () => {
describe('prefix', () => {
it('is [discover]', () => {
expect(DiscoverQueryKeyFactory.prefix).toEqual(['discover']);
});
});
describe('discover', () => {
it('returns expected key shape', () => {
const key = DiscoverQueryKeyFactory.discover('listenbrainz');
expect(key).toEqual(['discover', 'listenbrainz']);
});
it('produces different keys for different sources', () => {
const lbKey = DiscoverQueryKeyFactory.discover('listenbrainz');
const lfmKey = DiscoverQueryKeyFactory.discover('lastfm');
expect(lbKey).not.toEqual(lfmKey);
});
it('starts with the discover prefix', () => {
const key = DiscoverQueryKeyFactory.discover('listenbrainz');
expect(key[0]).toBe('discover');
});
});
describe('playlistSuggestions', () => {
it('returns expected key shape with no source', () => {
const key = DiscoverQueryKeyFactory.playlistSuggestions('pl-1');
expect(key).toEqual(['discover', 'playlist-suggestions', 'pl-1', null]);
});
it('returns expected key shape with source', () => {
const key = DiscoverQueryKeyFactory.playlistSuggestions('pl-1', 'listenbrainz');
expect(key).toEqual(['discover', 'playlist-suggestions', 'pl-1', 'listenbrainz']);
});
it('produces different keys for different playlist IDs', () => {
const key1 = DiscoverQueryKeyFactory.playlistSuggestions('pl-1');
const key2 = DiscoverQueryKeyFactory.playlistSuggestions('pl-2');
expect(key1).not.toEqual(key2);
});
it('produces different keys for different sources', () => {
const lbKey = DiscoverQueryKeyFactory.playlistSuggestions('pl-1', 'listenbrainz');
const lfmKey = DiscoverQueryKeyFactory.playlistSuggestions('pl-1', 'lastfm');
expect(lbKey).not.toEqual(lfmKey);
});
it('null source matches omitted source', () => {
const noSource = DiscoverQueryKeyFactory.playlistSuggestions('pl-1');
const nullSource = DiscoverQueryKeyFactory.playlistSuggestions('pl-1', null);
expect(noSource).toEqual(nullSource);
});
it('starts with the discover prefix', () => {
const key = DiscoverQueryKeyFactory.playlistSuggestions('pl-1');
expect(key[0]).toBe('discover');
});
});
describe('cross-key isolation', () => {
it('discover and playlistSuggestions keys differ for same string input', () => {
const discoverKey = DiscoverQueryKeyFactory.discover('listenbrainz');
const playlistKey = DiscoverQueryKeyFactory.playlistSuggestions('listenbrainz');
expect(discoverKey).not.toEqual(playlistKey);
});
});
});
describe('getPlaylistSuggestionsQuery source propagation', () => {
it('passes listenbrainz source in request body', async () => {
mockPost.mockResolvedValue({ playlist_id: 'pl-1', suggestions: { items: [] } });
const { getPlaylistSuggestionsQuery } = await import('./DiscoverQuery.svelte');
const getter = () => ({
playlistId: 'pl-1',
count: 10,
source: 'listenbrainz' as const,
enabled: true
});
getPlaylistSuggestionsQuery(getter);
expect(mockCreateQuery).toHaveBeenCalled();
const factory = mockCreateQuery.mock.calls[
mockCreateQuery.mock.calls.length - 1
][0] as unknown as () => Record<string, unknown>;
const opts = factory();
const queryFn = opts.queryFn as (ctx: { signal: AbortSignal }) => Promise<unknown>;
await queryFn({ signal: new AbortController().signal });
expect(mockPost).toHaveBeenCalledTimes(1);
const [, body] = mockPost.mock.calls[0];
expect((body as Record<string, unknown>).source).toBe('listenbrainz');
});
it('passes lastfm source in request body', async () => {
mockPost.mockResolvedValue({ playlist_id: 'pl-1', suggestions: { items: [] } });
const { getPlaylistSuggestionsQuery } = await import('./DiscoverQuery.svelte');
const getter = () => ({
playlistId: 'pl-1',
count: 10,
source: 'lastfm' as const,
enabled: true
});
getPlaylistSuggestionsQuery(getter);
const factory = mockCreateQuery.mock.calls[
mockCreateQuery.mock.calls.length - 1
][0] as unknown as () => Record<string, unknown>;
const opts = factory();
const queryFn = opts.queryFn as (ctx: { signal: AbortSignal }) => Promise<unknown>;
await queryFn({ signal: new AbortController().signal });
expect(mockPost).toHaveBeenCalledTimes(1);
const [, body] = mockPost.mock.calls[0];
expect((body as Record<string, unknown>).source).toBe('lastfm');
});
});
describe('getRadioQuery source propagation', () => {
it('passes source in request body', async () => {
mockPost.mockResolvedValue({ items: [] });
const { getRadioQuery } = await import('./DiscoverQuery.svelte');
const getter = () => ({
seedType: 'artist',
seedId: 'mbid-123',
source: 'listenbrainz' as const
});
getRadioQuery(getter);
const factory = mockCreateQuery.mock.calls[
mockCreateQuery.mock.calls.length - 1
][0] as unknown as () => Record<string, unknown>;
const opts = factory();
const queryFn = opts.queryFn as (ctx: { signal: AbortSignal }) => Promise<unknown>;
await queryFn({ signal: new AbortController().signal });
expect(mockPost).toHaveBeenCalledTimes(1);
const [, body] = mockPost.mock.calls[0];
expect((body as Record<string, unknown>).source).toBe('listenbrainz');
});
it('passes lastfm source in request body', async () => {
mockPost.mockResolvedValue({ items: [] });
const { getRadioQuery } = await import('./DiscoverQuery.svelte');
const getter = () => ({
seedType: 'artist',
seedId: 'mbid-456',
source: 'lastfm' as const
});
getRadioQuery(getter);
const factory = mockCreateQuery.mock.calls[
mockCreateQuery.mock.calls.length - 1
][0] as unknown as () => Record<string, unknown>;
const opts = factory();
const queryFn = opts.queryFn as (ctx: { signal: AbortSignal }) => Promise<unknown>;
await queryFn({ signal: new AbortController().signal });
expect(mockPost).toHaveBeenCalledTimes(1);
const [, body] = mockPost.mock.calls[0];
expect((body as Record<string, unknown>).source).toBe('lastfm');
});
});
@@ -0,0 +1,79 @@
import { api } from '$lib/api/client';
import { API, CACHE_TTL } from '$lib/constants';
import type { MusicSource } from '$lib/stores/musicSource';
import type { DiscoverResponse, HomeSection, PlaylistSuggestionsResponse } from '$lib/types';
import { createQuery, queryOptions } from '@tanstack/svelte-query';
import type { Getter } from 'runed';
import { DiscoverQueryKeyFactory } from './DiscoverQueryKeyFactory';
export const getDiscoverQueryOptions = (source: MusicSource) =>
queryOptions({
staleTime: CACHE_TTL.DISCOVER,
queryKey: DiscoverQueryKeyFactory.discover(source),
queryFn: ({ signal }) =>
api.global.get<DiscoverResponse>(API.discover(source), {
signal
})
});
export const getDiscoverQuery = (getSource: Getter<MusicSource>) =>
createQuery(() => ({
staleTime: CACHE_TTL.DISCOVER,
queryKey: DiscoverQueryKeyFactory.discover(getSource()),
queryFn: ({ signal }) =>
api.global.get<DiscoverResponse>(API.discover(getSource()), {
signal
}),
refetchInterval: (query: { state: { data?: DiscoverResponse | undefined } }) =>
query.state.data?.refreshing ? 3000 : false
}));
export const getRadioQuery = (
getParams: Getter<{ seedType: string; seedId: string; source: MusicSource }>
) =>
createQuery(() => ({
staleTime: CACHE_TTL.DISCOVER,
queryKey: DiscoverQueryKeyFactory.radio(
getParams().seedType,
getParams().seedId,
getParams().source
),
queryFn: ({ signal }) =>
api.global.post<HomeSection>(
API.discoverRadio(),
{
seed_type: getParams().seedType,
seed_id: getParams().seedId,
source: getParams().source
},
{ signal }
),
enabled: !!getParams().seedId
}));
export const getPlaylistSuggestionsQuery = (
getParams: Getter<{
playlistId: string;
count?: number;
source?: MusicSource | null;
enabled?: boolean;
}>
) =>
createQuery(() => ({
staleTime: CACHE_TTL.DISCOVER,
queryKey: DiscoverQueryKeyFactory.playlistSuggestions(
getParams().playlistId,
getParams().source
),
queryFn: ({ signal }) =>
api.global.post<PlaylistSuggestionsResponse>(
API.discoverPlaylistSuggestions(),
{
playlist_id: getParams().playlistId,
count: getParams().count ?? 15,
source: getParams().source ?? undefined
},
{ signal }
),
enabled: (getParams().enabled ?? true) && !!getParams().playlistId
}));
@@ -0,0 +1,10 @@
import type { MusicSource } from '$lib/stores/musicSource';
export const DiscoverQueryKeyFactory = {
prefix: ['discover'] as const,
discover: (source: MusicSource) => [...DiscoverQueryKeyFactory.prefix, source] as const,
radio: (seedType: string, seedId: string, source: MusicSource) =>
[...DiscoverQueryKeyFactory.prefix, 'radio', seedType, seedId, { source }] as const,
playlistSuggestions: (playlistId: string, source?: MusicSource | null) =>
[...DiscoverQueryKeyFactory.prefix, 'playlist-suggestions', playlistId, source ?? null] as const
};
@@ -0,0 +1,13 @@
import { CACHE_TTL } from '$lib/constants';
import { fetchPlaylists } from '$lib/api/playlists';
import { createQuery } from '@tanstack/svelte-query';
import type { Getter } from 'runed';
import { PlaylistQueryKeyFactory } from './PlaylistQueryKeyFactory';
export const getPlaylistListQuery = (getEnabled: Getter<boolean>) =>
createQuery(() => ({
staleTime: CACHE_TTL.DEFAULT,
queryKey: PlaylistQueryKeyFactory.list(),
queryFn: () => fetchPlaylists(),
enabled: getEnabled()
}));
@@ -0,0 +1,4 @@
export const PlaylistQueryKeyFactory = {
prefix: ['playlists'] as const,
list: () => [...PlaylistQueryKeyFactory.prefix, 'list'] as const
};
-2
View File
@@ -2,7 +2,6 @@ import { browser } from '$app/environment';
import { CACHE_TTL } from '$lib/constants';
import { api } from '$lib/api/client';
import { updateHomeCacheTTL } from '$lib/utils/homeCache';
import { updateDiscoverCacheTTL } from '$lib/utils/discoverCache';
import { updateDiscoveryCacheTTL } from '$lib/stores/discoveryCache';
import { updateDiscoverQueueCacheTTL } from '$lib/utils/discoverQueueCache';
import { updateSearchCacheTTL } from '$lib/stores/search';
@@ -50,7 +49,6 @@ let initialized = false;
function applyTTLs(ttls: CacheTTLs): void {
updateHomeCacheTTL(ttls.home);
updateDiscoverCacheTTL(ttls.discover);
libraryStore.updateCacheTTL(ttls.library);
recentlyAddedStore.updateCacheTTL(ttls.recentlyAdded);
updateDiscoveryCacheTTL(ttls.discover);
+31
View File
@@ -322,6 +322,8 @@ export type HomeSection = {
source: string | null;
fallback_message: string | null;
connect_service: string | null;
radio_seed_type?: string | null;
radio_seed_id?: string | null;
};
export type ServicePrompt = {
@@ -402,11 +404,40 @@ export type DiscoverResponse = {
lastfm_weekly_artist_chart: HomeSection | null;
lastfm_weekly_album_chart: HomeSection | null;
lastfm_recent_scrobbles: HomeSection | null;
daily_mixes: HomeSection[];
radio_sections: HomeSection[];
discover_picks: HomeSection | null;
unexplored_genres: HomeSection | null;
genre_artists: Record<string, string | null>;
genre_artist_images: Record<string, string | null>;
integration_status: Record<string, boolean>;
service_prompts: ServicePrompt[];
refreshing: boolean;
service_status: Record<string, string> | null;
};
export type RadioRequest = {
seed_type: 'artist' | 'album' | 'genre';
seed_id: string;
count?: number;
source?: string | null;
};
export type PlaylistProfile = {
artist_mbids: string[];
genre_distribution: Record<string, string[]>;
track_count: number;
};
export type PlaylistSuggestionsRequest = {
playlist_id: string;
count?: number;
};
export type PlaylistSuggestionsResponse = {
suggestions: HomeSection;
playlist_id: string;
profile: PlaylistProfile;
};
export type QualityProfile = {
-14
View File
@@ -1,14 +0,0 @@
import { CACHE_KEYS, CACHE_TTL } from '$lib/constants';
import { createLocalStorageCache } from '$lib/utils/localStorageCache';
import type { DiscoverResponse } from '$lib/types';
const discoverCache = createLocalStorageCache<DiscoverResponse>(
CACHE_KEYS.DISCOVER_CACHE,
CACHE_TTL.DISCOVER
);
export const getDiscoverCachedData = discoverCache.get;
export const setDiscoverCachedData = discoverCache.set;
export const isDiscoverCacheStale = discoverCache.isStale;
export const updateDiscoverCacheTTL = discoverCache.updateTTL;
export const removeDiscoverCachedData = discoverCache.remove;
@@ -154,6 +154,7 @@
trackLinks={state.trackLinks}
youtubeEnabled={$integrationStore.youtube}
youtubeApiConfigured={$integrationStore.youtube_api}
previewCacheMap={state.previewCacheMap}
jellyfinEnabled={$integrationStore.jellyfin}
localfilesEnabled={$integrationStore.localfiles}
navidromeEnabled={$integrationStore.navidrome}
@@ -21,6 +21,7 @@
import { playerStore } from '$lib/stores/player.svelte';
import NowPlayingIndicator from '$lib/components/NowPlayingIndicator.svelte';
import TrackPlayButton from '$lib/components/TrackPlayButton.svelte';
import TrackPreviewButton from '$lib/components/TrackPreviewButton.svelte';
import TrackSourceButton from '$lib/components/TrackSourceButton.svelte';
import ContextMenu from '$lib/components/ContextMenu.svelte';
import JellyfinIcon from '$lib/components/JellyfinIcon.svelte';
@@ -47,6 +48,7 @@
trackLinks: YouTubeTrackLink[];
youtubeEnabled: boolean;
youtubeApiConfigured: boolean;
previewCacheMap: Map<string, boolean>;
jellyfinEnabled: boolean;
localfilesEnabled: boolean;
navidromeEnabled: boolean;
@@ -87,6 +89,7 @@
trackLinks,
youtubeEnabled,
youtubeApiConfigured,
previewCacheMap,
jellyfinEnabled,
localfilesEnabled,
navidromeEnabled,
@@ -152,6 +155,13 @@
{@const showLocalBtn = localfilesEnabled && localMatch?.found}
{@const showNavidromeBtn = navidromeEnabled && navidromeMatch?.found}
{@const showPlexBtn = plexEnabled && plexMatch?.found}
{@const hasAnySource =
tl !== null ||
jellyfinTrack !== null ||
localTrack !== null ||
navidromeTrack !== null ||
plexTrack !== null}
{@const showPreview = youtubeApiConfigured && !hasAnySource}
<li
class="list-row group hover:bg-base-300/50 transition-colors p-3 sm:p-4"
style={isCurrentlyPlaying ? `background-color: ${colors.accent}20;` : ''}
@@ -183,8 +193,22 @@
{formatDuration(track.length)}
</div>
{#if youtubeEnabled || showJellyfinBtn || showLocalBtn || showNavidromeBtn || showPlexBtn}
{#if youtubeEnabled || showPreview || showJellyfinBtn || showLocalBtn || showNavidromeBtn || showPlexBtn}
<div class="flex items-center gap-1.5 shrink-0 ml-auto">
{#if showPreview}
<TrackPreviewButton
artist={album.artist_name}
track={track.title}
ytConfigured={youtubeApiConfigured}
initialCached={previewCacheMap.get(
`${album.artist_name.toLowerCase()}|${track.title.toLowerCase()}`
) ?? null}
albumId={album.musicbrainz_id}
coverUrl={album.cover_url ?? null}
artistId={album.artist_id}
/>
{/if}
{#if youtubeEnabled}
<TrackPlayButton
trackNumber={track.position}
@@ -1,6 +1,5 @@
import type {
AlbumBasicInfo,
AlbumTracksInfo,
MoreByArtistResponse,
SimilarAlbumsResponse,
YouTubeLink,
@@ -15,6 +14,8 @@ import { api } from '$lib/api/client';
import { API } from '$lib/constants';
import { compareDiscTrack } from '$lib/player/queueHelpers';
export { fetchAlbumTracks } from '$lib/api/albums';
export async function fetchAlbumBasic(
albumId: string,
signal?: AbortSignal
@@ -22,13 +23,6 @@ export async function fetchAlbumBasic(
return api.get<AlbumBasicInfo>(`/api/v1/albums/${albumId}/basic`, { signal });
}
export async function fetchAlbumTracks(
albumId: string,
signal?: AbortSignal
): Promise<AlbumTracksInfo> {
return api.get<AlbumTracksInfo>(`/api/v1/albums/${albumId}/tracks`, { signal });
}
export async function fetchDiscovery(
albumId: string,
artistId: string,
@@ -18,11 +18,13 @@ import type {
NavidromeTrackInfo,
PlexAlbumMatch,
PlexTrackInfo,
LastFmAlbumEnrichment
LastFmAlbumEnrichment,
TrackCacheCheckItem
} from '$lib/types';
import { libraryStore } from '$lib/stores/library';
import { monitoredArtistsStore } from '$lib/stores/monitoredArtists';
import { integrationStore } from '$lib/stores/integration';
import { API } from '$lib/constants';
import { isAbortError } from '$lib/utils/errorHandling';
import { extractServiceStatus } from '$lib/utils/serviceStatus';
import { api } from '$lib/api/client';
@@ -113,6 +115,9 @@ export function createAlbumPageState(albumIdGetter: () => string) {
let pollTimer: ReturnType<typeof setInterval> | null = null;
let artistInLidarr = $state(false);
let artistMonitored = $state(false);
const previewCacheMap = new SvelteMap<string, boolean>();
let lastPreviewCacheKey = '';
let previewCacheAbort: AbortController | null = null;
const trackLinkMap = $derived.by(
() => new SvelteMap(trackLinks.map((tl) => [getDiscTrackKey(tl), tl]))
@@ -143,11 +148,49 @@ export function createAlbumPageState(albumIdGetter: () => string) {
!!(album && (album.monitored || libraryStore.isMonitored(album.musicbrainz_id)))
);
$effect(() => {
const artist = album?.artist_name;
const tracks = tracksInfo?.tracks;
if (!artist || !tracks || tracks.length === 0) return;
const integrations = get(integrationStore);
if (!integrations.youtube_api) return;
const key = `${artist}|${tracks.map((t) => t.title).join('|')}`;
if (key === lastPreviewCacheKey) return;
lastPreviewCacheKey = key;
previewCacheAbort?.abort();
previewCacheAbort = new AbortController();
const signal = previewCacheAbort.signal;
(async () => {
try {
const data = await api.global.post<{ items: TrackCacheCheckItem[] }>(
API.discoverQueueYoutubeCacheCheck(),
{ items: tracks.map((t) => ({ artist, track: t.title })) },
{ signal }
);
if (lastPreviewCacheKey === key) {
for (const item of data.items) {
previewCacheMap.set(
`${item.artist.toLowerCase()}|${item.track.toLowerCase()}`,
item.cached
);
}
}
} catch (e) {
if (isAbortError(e)) return;
}
})();
});
function resetState() {
if (abortController) {
abortController.abort();
abortController = null;
}
previewCacheAbort?.abort();
previewCacheAbort = null;
stopPolling();
album = null;
tracksInfo = null;
@@ -173,6 +216,8 @@ export function createAlbumPageState(albumIdGetter: () => string) {
lastfmEnrichment = null;
loadingLastfm = true;
refreshing = false;
previewCacheMap.clear();
lastPreviewCacheKey = '';
}
function hydrateFromCache(albumId: string) {
@@ -807,6 +852,9 @@ export function createAlbumPageState(albumIdGetter: () => string) {
get plexTrackMap() {
return plexTrackMap;
},
get previewCacheMap() {
return previewCacheMap;
},
get inLibrary() {
return inLibrary;
},
+282 -285
View File
@@ -1,248 +1,82 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { beforeNavigate } from '$app/navigation';
import HomeSection from '$lib/components/HomeSection.svelte';
import DailyMixCard from '$lib/components/DailyMixCard.svelte';
import RadioCard from '$lib/components/RadioCard.svelte';
import DiscoverPicksSection from '$lib/components/DiscoverPicksSection.svelte';
import GenrePills from '$lib/components/GenrePills.svelte';
import GenreGrid from '$lib/components/GenreGrid.svelte';
import DiscoverQueueCard from '$lib/components/DiscoverQueueCard.svelte';
import DiscoverQueueModal from '$lib/components/DiscoverQueueModal.svelte';
import PlaylistDiscoveryModal from '$lib/components/PlaylistDiscoveryModal.svelte';
import WeeklyExploration from '$lib/components/WeeklyExploration.svelte';
import ServicePromptCard from '$lib/components/ServicePromptCard.svelte';
import SourceSwitcher from '$lib/components/SourceSwitcher.svelte';
import DiscoverArtistHero from '$lib/components/DiscoverArtistHero.svelte';
import DiscoverArtistMiniBand from '$lib/components/DiscoverArtistMiniBand.svelte';
import SectionDivider from '$lib/components/SectionDivider.svelte';
import type { DiscoverResponse } from '$lib/types';
import CarouselSkeleton from '$lib/components/CarouselSkeleton.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import {
getDiscoverCachedData,
setDiscoverCachedData,
isDiscoverCacheStale
} from '$lib/utils/discoverCache';
import { removeAllQueueCachedData } from '$lib/utils/discoverQueueCache';
import { isAbortError } from '$lib/utils/errorHandling';
import { api } from '$lib/api/client';
import { isDismissed } from '$lib/utils/dismissedPrompts';
import { musicSourceStore, type MusicSource } from '$lib/stores/musicSource';
import { discoverQueueStatusStore } from '$lib/stores/discoverQueueStatus';
import { Compass, CircleAlert, Sparkles, Music, BarChart3 } from 'lucide-svelte';
import {
Compass,
CircleAlert,
Sparkles,
Music,
Music2,
Radio,
Library,
TrendingUp,
LayoutGrid,
Wand2,
Heart
} from 'lucide-svelte';
import { getDiscoverQuery } from '$lib/queries/discover/DiscoverQuery.svelte';
import { DiscoverQueryKeyFactory } from '$lib/queries/discover/DiscoverQueryKeyFactory';
import { invalidateQueriesWithPersister } from '$lib/queries/QueryClient';
import { API } from '$lib/constants';
let discoverData = $state<DiscoverResponse | null>(null);
let loading = $state(true);
let refreshing = $state(false);
let isUpdating = $state(false);
let error = $state('');
let lastUpdated = $state<Date | null>(null);
let abortController: AbortController | null = null;
let queueModalOpen = $state(false);
let playlistDiscoverOpen = $state(false);
let activeSource: MusicSource = $state('listenbrainz');
let pollRunId = 0;
function resolveDiscoverSource(source?: MusicSource): MusicSource {
return source ?? activeSource;
const discoverQuery = getDiscoverQuery(() => activeSource);
const discoverData = $derived(discoverQuery.data ?? null);
const loading = $derived(discoverQuery.isLoading);
const refreshing = $derived(discoverQuery.isFetching && !discoverQuery.isLoading);
const isUpdating = $derived(discoverQuery.isRefetching && !!discoverData);
const lastUpdated = $derived(
discoverQuery.dataUpdatedAt ? new Date(discoverQuery.dataUpdatedAt) : null
);
const error = $derived(discoverQuery.error?.message ?? '');
async function handleRefresh() {
await api.global.post(API.discoverRefresh());
await invalidateQueriesWithPersister({
queryKey: DiscoverQueryKeyFactory.discover(activeSource)
});
}
function cancelDiscoverPolling(): void {
pollRunId += 1;
}
async function loadDiscoverData(forceRefresh = false, sourceOverride?: MusicSource) {
const source = resolveDiscoverSource(sourceOverride);
const cached = getDiscoverCachedData(source);
if (cached && !forceRefresh) {
// Guard: when the backend is still computing async recommendations,
// all personalized sections are null/empty. Don't use a cached response
// that has no content — it represents a transient "still computing" state.
const cachedHasContent =
(cached.data.because_you_listen_to?.length ?? 0) > 0 ||
cached.data.fresh_releases != null ||
cached.data.missing_essentials != null ||
cached.data.globally_trending != null;
if (cachedHasContent) {
discoverData = cached.data;
lastUpdated = new Date(cached.timestamp);
loading = false;
if (isDiscoverCacheStale(cached.timestamp)) {
refreshInBackground(source);
}
return;
}
}
if (abortController) abortController.abort();
abortController = new AbortController();
cancelDiscoverPolling();
if (!discoverData) {
loading = true;
} else {
refreshing = true;
}
error = '';
try {
const data: DiscoverResponse = await api.get(
`/api/v1/discover?source=${encodeURIComponent(source)}`,
{
signal: abortController.signal
}
);
discoverData = data;
lastUpdated = new Date();
const dataHasContent =
(data.because_you_listen_to?.length ?? 0) > 0 ||
data.fresh_releases != null ||
data.missing_essentials != null ||
data.globally_trending != null;
if (dataHasContent) {
setDiscoverCachedData(data, source);
}
if (!dataHasContent && data.refreshing) {
pollForReady(source);
}
} catch (e) {
if (isAbortError(e)) return;
if (!discoverData) error = "Couldn't load Discover right now";
} finally {
loading = false;
refreshing = false;
}
}
async function refreshInBackground(sourceOverride?: MusicSource) {
if (refreshing) return;
if (abortController) abortController.abort();
abortController = new AbortController();
refreshing = true;
isUpdating = true;
const source = resolveDiscoverSource(sourceOverride);
try {
const data: DiscoverResponse = await api.get(
`/api/v1/discover?source=${encodeURIComponent(source)}`,
{
signal: abortController.signal
}
);
const hasContent =
(data.because_you_listen_to?.length ?? 0) > 0 ||
data.fresh_releases != null ||
data.missing_essentials != null ||
data.globally_trending != null;
if (hasContent) {
discoverData = data;
lastUpdated = new Date();
setDiscoverCachedData(data, source);
}
} catch (e) {
if (isAbortError(e)) return;
} finally {
refreshing = false;
isUpdating = false;
}
}
async function pollForReady(source: MusicSource) {
const runId = ++pollRunId;
isUpdating = true;
try {
for (let i = 0; i < 15; i++) {
if (runId !== pollRunId) return;
await new Promise((r) => setTimeout(r, 3000));
if (runId !== pollRunId) return;
try {
const data: DiscoverResponse = await api.get(
`/api/v1/discover?source=${encodeURIComponent(source)}`
);
const ready =
(data.because_you_listen_to?.length ?? 0) > 0 ||
data.fresh_releases != null ||
data.missing_essentials != null ||
data.globally_trending != null;
if (ready || !data.refreshing) {
discoverData = data;
lastUpdated = new Date();
if (ready) setDiscoverCachedData(data, source);
break;
}
} catch (e) {
if (isAbortError(e)) return;
break;
}
}
} finally {
if (runId === pollRunId) {
isUpdating = false;
}
}
}
async function handleRefresh(sourceOverride?: MusicSource) {
const source = resolveDiscoverSource(sourceOverride);
const runId = ++pollRunId;
refreshing = true;
isUpdating = true;
try {
await api.global.post('/api/v1/discover/refresh');
} catch {
// Ignore errors
}
try {
const maxPolls = 30;
for (let i = 0; i < maxPolls; i++) {
if (runId !== pollRunId) return;
await new Promise((r) => setTimeout(r, 2000));
if (runId !== pollRunId) return;
try {
const data: DiscoverResponse = await api.get(
`/api/v1/discover?source=${encodeURIComponent(source)}`
);
if (!data.refreshing) {
discoverData = data;
lastUpdated = new Date();
setDiscoverCachedData(data, source);
break;
}
} catch (e) {
if (isAbortError(e)) return;
break;
}
}
} finally {
if (runId === pollRunId) {
refreshing = false;
isUpdating = false;
}
}
}
function cleanup() {
cancelDiscoverPolling();
if (abortController) {
abortController.abort();
abortController = null;
}
function handleSourceChange(source: MusicSource) {
activeSource = source;
removeAllQueueCachedData();
discoverQueueStatusStore.reset();
discoverQueueStatusStore.init(source);
}
onMount(async () => {
await musicSourceStore.load();
activeSource = musicSourceStore.getPageSource('discover');
loadDiscoverData(false, activeSource);
discoverQueueStatusStore.init(activeSource);
});
onDestroy(() => {
cleanup();
discoverQueueStatusStore.stopPolling();
});
beforeNavigate(cleanup);
function handleSourceChange(source: MusicSource) {
activeSource = source;
removeAllQueueCachedData();
loadDiscoverData(true, source);
discoverQueueStatusStore.reset();
discoverQueueStatusStore.init(source);
}
let hasContent = $derived(
(discoverData?.because_you_listen_to?.length ?? 0) > 0 ||
@@ -255,38 +89,84 @@
discoverData?.lastfm_weekly_artist_chart != null ||
discoverData?.lastfm_weekly_album_chart != null ||
discoverData?.lastfm_recent_scrobbles != null ||
(discoverData?.genre_list?.items?.length ?? 0) > 0
(discoverData?.genre_list?.items?.length ?? 0) > 0 ||
(discoverData?.daily_mixes?.length ?? 0) > 0 ||
(discoverData?.radio_sections?.length ?? 0) > 0 ||
discoverData?.discover_picks != null ||
discoverData?.unexplored_genres != null
);
let servicePrompts = $derived(
(discoverData?.service_prompts ?? []).filter((p) => !isDismissed(p.service))
let dismissVersion = $state(0);
let servicePrompts = $derived.by(() => {
void dismissVersion;
return (discoverData?.service_prompts ?? []).filter((p) => !isDismissed(p.service));
});
let hasWeeklyExploration = $derived(
activeSource === 'listenbrainz' &&
!!discoverData?.weekly_exploration &&
discoverData.weekly_exploration.tracks.length > 0
);
let hasCuratedGroup = $derived(
let queueIsHero = $derived(!hasWeeklyExploration && !!discoverData?.discover_queue_enabled);
let hasHero = $derived(hasWeeklyExploration || queueIsHero);
let showQueueInQuickActions = $derived(!!discoverData?.discover_queue_enabled && !queueIsHero);
let hasMadeForYou = $derived(
(discoverData?.daily_mixes?.length ?? 0) > 0 || (discoverData?.radio_sections?.length ?? 0) > 0
);
let hasBecauseYouListened = $derived(
(discoverData?.because_you_listen_to?.length ?? 0) > 0 ||
discoverData?.discover_queue_enabled ||
(activeSource === 'listenbrainz' &&
discoverData?.weekly_exploration &&
discoverData.weekly_exploration.tracks.length > 0)
);
let hasExploreGroup = $derived(
(discoverData?.fresh_releases?.items?.length ?? 0) > 0 ||
(discoverData?.missing_essentials?.items?.length ?? 0) > 0 ||
(discoverData?.rediscover?.items?.length ?? 0) > 0 ||
(discoverData?.artists_you_might_like?.items?.length ?? 0) > 0 ||
(discoverData?.popular_in_your_genres?.items?.length ?? 0) > 0
);
let hasChartsGroup = $derived(
(discoverData?.globally_trending?.items?.length ?? 0) > 0 ||
(discoverData?.lastfm_recent_scrobbles?.items?.length ?? 0) > 0 ||
(discoverData?.lastfm_weekly_artist_chart?.items?.length ?? 0) > 0 ||
(discoverData?.lastfm_weekly_album_chart?.items?.length ?? 0) > 0 ||
let hasNewFresh = $derived(
(discoverData?.fresh_releases?.items?.length ?? 0) > 0 ||
(discoverData?.discover_picks?.items?.length ?? 0) > 0 ||
(discoverData?.missing_essentials?.items?.length ?? 0) > 0
);
let hasFromYourLibrary = $derived(
(discoverData?.rediscover?.items?.length ?? 0) > 0 ||
(discoverData?.lastfm_recent_scrobbles?.items?.length ?? 0) > 0
);
let hasBrowseGenres = $derived(
(discoverData?.unexplored_genres?.items?.length ?? 0) > 0 ||
(discoverData?.genre_list?.items?.length ?? 0) > 0
);
let hasTrending = $derived(
(discoverData?.globally_trending?.items?.length ?? 0) > 0 ||
(discoverData?.lastfm_weekly_artist_chart?.items?.length ?? 0) > 0 ||
(discoverData?.lastfm_weekly_album_chart?.items?.length ?? 0) > 0
);
let shuffledGenres = $state<
{ name: string; listen_count?: number | null; artist_count?: number | null }[]
>([]);
$effect(() => {
const items = discoverData?.unexplored_genres?.items;
if (items && items.length > 0) {
shuffledGenres = [...(items as typeof shuffledGenres)];
} else {
shuffledGenres = [];
}
});
function shuffleGenres() {
const copy = [...shuffledGenres];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
shuffledGenres = copy;
}
function handlePromptDismiss(_service: string) {
servicePrompts = (discoverData?.service_prompts ?? []).filter((p) => !isDismissed(p.service));
dismissVersion++;
}
</script>
@@ -296,7 +176,7 @@
<div class="min-h-[calc(100vh-200px)]">
<PageHeader
subtitle="Personalized music recommendations based on your listening habits."
subtitle="Music recommendations based on what you listen to."
gradientClass="bg-gradient-to-br from-info/30 via-primary/20 to-secondary/10"
{loading}
{refreshing}
@@ -319,8 +199,7 @@
<div class="mt-16 flex flex-col items-center justify-center px-4">
<CircleAlert class="mb-4 h-10 w-10 text-base-content/50" />
<p class="text-base-content/70">{error}</p>
<button class="btn btn-primary mt-4" onclick={() => loadDiscoverData(true, activeSource)}
>Try Again</button
<button class="btn btn-primary mt-4" onclick={() => discoverQuery.refetch()}>Try Again</button
>
</div>
{:else}
@@ -344,47 +223,137 @@
</div>
{:else if discoverData}
<div class="space-y-10 sm:space-y-12">
{#if hasCuratedGroup}
<!-- §1 HERO -->
{#if hasHero}
<div class="discover-section-enter">
{#if hasWeeklyExploration && discoverData.weekly_exploration}
<WeeklyExploration
section={discoverData.weekly_exploration}
ytConfigured={discoverData.integration_status?.youtube ?? false}
/>
{:else if queueIsHero}
<DiscoverQueueCard source={activeSource} onLaunch={() => (queueModalOpen = true)} />
{/if}
</div>
{/if}
<!-- §2 QUICK ACTIONS -->
<div class="discover-section-enter">
<div class="grid grid-cols-1 gap-4 {showQueueInQuickActions ? 'md:grid-cols-2' : ''}">
{#if showQueueInQuickActions}
<DiscoverQueueCard source={activeSource} onLaunch={() => (queueModalOpen = true)} />
{/if}
<button
type="button"
class="group relative w-full overflow-hidden rounded-2xl border border-primary/15 bg-gradient-to-br from-primary/8 via-base-200/50 to-secondary/8 px-5 py-7 backdrop-blur-sm shadow-[0_4px_24px_oklch(var(--p)/0.06)] transition-all duration-300 cursor-pointer text-left motion-safe:hover:-translate-y-0.5 hover:shadow-[0_8px_32px_oklch(var(--p)/0.15)] hover:border-primary/25 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-base-100"
onclick={() => (playlistDiscoverOpen = true)}
>
<div
class="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-br from-white/[0.03] to-transparent"
></div>
<div class="flex items-center gap-4">
<div
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary/15 shadow-[0_0_16px_oklch(var(--p)/0.15)]"
>
<Wand2 class="h-5 w-5 text-primary" />
</div>
<div class="flex-1 min-w-0">
<h3 class="font-bold text-sm sm:text-base">Discover for a Playlist</h3>
<p class="text-xs text-base-content/50 mt-0.5">
Get album suggestions based on any playlist.
</p>
</div>
<div
class="shrink-0 text-primary/50 transition-transform duration-300 group-hover:translate-x-1"
>
<Sparkles class="h-5 w-5" />
</div>
</div>
</button>
</div>
</div>
<!-- §3 MADE FOR YOU -->
{#if hasMadeForYou}
<div>
<SectionDivider label="Curated For You">
<SectionDivider label="Made For You">
{#snippet icon()}<Sparkles class="w-3.5 h-3.5" />{/snippet}
</SectionDivider>
<div class="discover-section-enter space-y-5 sm:space-y-6">
{#if discoverData.because_you_listen_to.length > 0}
{#each discoverData.because_you_listen_to as entry (entry.seed_artist_mbid || entry.seed_artist)}
<div>
<DiscoverArtistHero {entry} />
<div class="discover-section-enter space-y-8 sm:space-y-10">
{#if discoverData.daily_mixes?.length}
<div>
<h3
class="text-sm font-semibold text-base-content/70 mb-3 flex items-center gap-2"
>
<Music2 class="h-4 w-4" />
Daily Mixes
</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each discoverData.daily_mixes as mix, i (`${mix.title}-${i}`)}
<DailyMixCard section={mix} />
{/each}
</div>
{/each}
</div>
{/if}
<div>
<DiscoverQueueCard
source={activeSource}
onLaunch={() => (queueModalOpen = true)}
/>
</div>
{#if activeSource === 'listenbrainz' && discoverData.weekly_exploration && discoverData.weekly_exploration.tracks.length > 0}
{#if discoverData.radio_sections?.length}
<div>
<WeeklyExploration
section={discoverData.weekly_exploration}
ytConfigured={discoverData.integration_status?.youtube ?? false}
/>
<h3
class="text-sm font-semibold text-base-content/70 mb-3 flex items-center gap-2"
>
<Radio class="h-4 w-4" />
Radio Stations
</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each discoverData.radio_sections as radio (`radio-${radio.radio_seed_id}`)}
<RadioCard
seedType={radio.radio_seed_type ?? 'artist'}
seedId={radio.radio_seed_id ?? ''}
source={activeSource}
initialSection={radio}
/>
{/each}
</div>
</div>
{/if}
</div>
</div>
{:else}
{/if}
<!-- §4 BECAUSE YOU LISTENED -->
{#if hasBecauseYouListened}
<div>
<DiscoverQueueCard source={activeSource} onLaunch={() => (queueModalOpen = true)} />
<SectionDivider label="Because You Listened">
{#snippet icon()}<Heart class="w-3.5 h-3.5" />{/snippet}
</SectionDivider>
<div class="discover-section-enter space-y-5 sm:space-y-6">
{#each discoverData.because_you_listen_to as entry, i (entry.seed_artist_mbid || entry.seed_artist)}
<div>
{#if i === 0}
<DiscoverArtistHero {entry} />
{:else}
<DiscoverArtistMiniBand {entry} />
{/if}
</div>
{/each}
{#if discoverData.artists_you_might_like && discoverData.artists_you_might_like.items.length > 0}
<div><HomeSection section={discoverData.artists_you_might_like} /></div>
{/if}
{#if discoverData.popular_in_your_genres && discoverData.popular_in_your_genres.items.length > 0}
<div><HomeSection section={discoverData.popular_in_your_genres} /></div>
{/if}
</div>
</div>
{/if}
{#if hasExploreGroup}
<!-- §5 NEW & FRESH -->
{#if hasNewFresh}
<div>
<SectionDivider label="Explore New Music">
<SectionDivider label="New & Fresh">
{#snippet icon()}<Music class="w-3.5 h-3.5" />{/snippet}
</SectionDivider>
@@ -393,42 +362,52 @@
<HomeSection section={discoverData.fresh_releases} />
{/if}
{#if discoverData.discover_picks && discoverData.discover_picks.items.length > 0}
<DiscoverPicksSection section={discoverData.discover_picks} />
{/if}
{#if discoverData.missing_essentials && discoverData.missing_essentials.items.length > 0}
<HomeSection section={discoverData.missing_essentials} />
{/if}
{#if discoverData.rediscover && discoverData.rediscover.items.length > 0}
<HomeSection section={discoverData.rediscover} />
{/if}
{#if discoverData.artists_you_might_like && discoverData.artists_you_might_like.items.length > 0}
<HomeSection section={discoverData.artists_you_might_like} />
{/if}
{#if discoverData.popular_in_your_genres && discoverData.popular_in_your_genres.items.length > 0}
<HomeSection section={discoverData.popular_in_your_genres} />
{/if}
</div>
</div>
{/if}
{#if hasChartsGroup}
<!-- §6 FROM YOUR LIBRARY -->
{#if hasFromYourLibrary}
<div>
<SectionDivider label="Charts & Activity">
{#snippet icon()}<BarChart3 class="w-3.5 h-3.5" />{/snippet}
<SectionDivider label="From Your Library">
{#snippet icon()}<Library class="w-3.5 h-3.5" />{/snippet}
</SectionDivider>
<div class="discover-section-enter space-y-2">
{#if discoverData.globally_trending && discoverData.globally_trending.items.length > 0}
<HomeSection section={discoverData.globally_trending} />
{#if discoverData.rediscover && discoverData.rediscover.items.length > 0}
<HomeSection section={discoverData.rediscover} />
{/if}
{#if discoverData.lastfm_recent_scrobbles && discoverData.lastfm_recent_scrobbles.items.length > 0}
<HomeSection section={discoverData.lastfm_recent_scrobbles} />
{/if}
</div>
</div>
{/if}
{#if discoverData.lastfm_weekly_artist_chart && discoverData.lastfm_weekly_artist_chart.items.length > 0}
<HomeSection section={discoverData.lastfm_weekly_artist_chart} />
<!-- §7 BROWSE GENRES -->
{#if hasBrowseGenres}
<div>
<SectionDivider label="Browse Genres">
{#snippet icon()}<LayoutGrid class="w-3.5 h-3.5" />{/snippet}
</SectionDivider>
<div class="discover-section-enter space-y-2">
{#if discoverData.unexplored_genres && shuffledGenres.length > 0}
<div class="mt-4 mb-4">
<GenrePills
title={discoverData.unexplored_genres.title}
genres={shuffledGenres}
onShuffle={shuffleGenres}
/>
</div>
{/if}
{#if discoverData.genre_list && discoverData.genre_list.items.length > 0}
@@ -441,6 +420,25 @@
/>
</div>
{/if}
</div>
</div>
{/if}
<!-- §8 TRENDING NOW -->
{#if hasTrending}
<div>
<SectionDivider label="Trending Now">
{#snippet icon()}<TrendingUp class="w-3.5 h-3.5" />{/snippet}
</SectionDivider>
<div class="discover-section-enter space-y-2">
{#if discoverData.globally_trending && discoverData.globally_trending.items.length > 0}
<HomeSection section={discoverData.globally_trending} />
{/if}
{#if discoverData.lastfm_weekly_artist_chart && discoverData.lastfm_weekly_artist_chart.items.length > 0}
<HomeSection section={discoverData.lastfm_weekly_artist_chart} />
{/if}
{#if discoverData.lastfm_weekly_album_chart && discoverData.lastfm_weekly_album_chart.items.length > 0}
<HomeSection section={discoverData.lastfm_weekly_album_chart} />
@@ -457,18 +455,16 @@
Building Your Recommendations
</h2>
<p class="max-w-md px-4 text-center text-sm text-base-content/70 sm:text-base">
We're analyzing your listening history and building personalized recommendations.
This may take a moment on first load.
Looking through your listening history to build recommendations. Give it a moment
on first load.
</p>
</div>
{:else}
<div class="flex flex-col items-center justify-center py-12 sm:py-16">
<Compass class="mb-4 h-12 w-12 sm:mb-6 sm:h-14 sm:w-14 text-base-content/50" />
<h2 class="mb-2 text-center text-xl font-bold sm:text-2xl">
Building Recommendations
</h2>
<h2 class="mb-2 text-center text-xl font-bold sm:text-2xl">Still Loading</h2>
<p class="mb-6 max-w-md px-4 text-center text-sm text-base-content/70 sm:text-base">
Your personalized recommendations are being prepared. Try refreshing in a moment.
Your recommendations are still loading. Try refreshing.
</p>
<button
class="btn btn-primary"
@@ -489,8 +485,8 @@
Nothing to Discover Yet
</h2>
<p class="mb-6 max-w-md px-4 text-center text-sm text-base-content/70 sm:text-base">
Connect your music services to get personalized recommendations. The more services
you connect, the better your recommendations will be.
Connect a music service to get recommendations. The more you connect, the better
they get.
</p>
<a href="/settings" class="btn btn-primary">Connect Services</a>
</div>
@@ -502,3 +498,4 @@
</div>
<DiscoverQueueModal bind:open={queueModalOpen} source={activeSource} />
<PlaylistDiscoveryModal bind:open={playlistDiscoverOpen} source={activeSource} />
+11
View File
@@ -0,0 +1,11 @@
import { getDiscoverQueryOptions } from '$lib/queries/discover/DiscoverQuery.svelte';
import { queryClient } from '$lib/queries/QueryClient';
import { musicSourceStore } from '$lib/stores/musicSource';
import type { PageLoad } from './$types';
export const load: PageLoad = async () => {
const source = musicSourceStore.getCachedSource();
await queryClient.prefetchQuery(getDiscoverQueryOptions(source));
return {};
};
@@ -5,15 +5,17 @@
deletePlaylistCover,
type PlaylistDetail
} from '$lib/api/playlists';
import PlaylistDiscoveryModal from '$lib/components/PlaylistDiscoveryModal.svelte';
import { toastStore } from '$lib/stores/toast';
import { formatTotalDurationSec, formatRelativeTime } from '$lib/utils/formatting';
import PlaylistMosaic from '$lib/components/PlaylistMosaic.svelte';
import ContextMenu from '$lib/components/ContextMenu.svelte';
import type { MenuItem } from '$lib/components/ContextMenu.svelte';
import { Play, Shuffle, Pencil, Check, X, Tv } from 'lucide-svelte';
import { Play, Shuffle, Pencil, Check, X, Tv, Sparkles } from 'lucide-svelte';
import NavidromeIcon from '$lib/components/NavidromeIcon.svelte';
import PlexIcon from '$lib/components/PlexIcon.svelte';
import { getSourceColor, getSourceLabel } from '$lib/utils/sources';
import { musicSourceStore } from '$lib/stores/musicSource';
interface Props {
playlist: PlaylistDetail;
@@ -40,6 +42,7 @@
let coverInput = $state<HTMLInputElement | null>(null);
let uploading = $state(false);
let coverPreview = $state<string | null>(null);
let discoverModalOpen = $state(false);
$effect(() => {
if (editingName && nameInputEl) {
@@ -317,7 +320,23 @@
<Shuffle class="h-4 w-4" />
Shuffle
</button>
<button
type="button"
class="btn btn-ghost"
onclick={() => (discoverModalOpen = true)}
disabled={playlist.track_count === 0}
>
<Sparkles class="h-4 w-4" />
Discover
</button>
<ContextMenu items={getHeaderMenuItems()} position="end" size="sm" />
</div>
</div>
</div>
<PlaylistDiscoveryModal
bind:open={discoverModalOpen}
playlistId={playlist.id}
playlistName={playlist.name}
source={musicSourceStore.getCachedSource()}
/>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB