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