diff --git a/backend/api/v1/routes/lidarr_request.py b/backend/api/v1/routes/lidarr_request.py new file mode 100644 index 0000000..69fb514 --- /dev/null +++ b/backend/api/v1/routes/lidarr_request.py @@ -0,0 +1,52 @@ +"""Single-track Lidarr request API. Fork-only addition. + +Distinct from track_download (yt-dlp-worker proxy). This route asks the +configured Lidarr instance to add the album and grab JUST the requested +track via its native indexer pipeline, relying on the track-monitored +fork to gate the import so siblings don't land on disk. + +Requires LIDARR_URL pointed at a fork instance (currently +lidarr-shared on gnat:8688). +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query + +from api.v1.schemas.lidarr_request import ( + LidarrRequestAccepted, + LidarrRequestRequest, + LidarrRequestStatusResponse, +) +from core.dependencies import get_lidarr_request_service +from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute +from services.lidarr_request_service import LidarrRequestService + +router = APIRouter( + route_class=MsgSpecRoute, + prefix="/lidarr-request", + tags=["lidarr-request"], +) + + +@router.post("", response_model=LidarrRequestAccepted, status_code=202) +async def request_track_via_lidarr( + body: LidarrRequestRequest = MsgSpecBody(LidarrRequestRequest), + service: LidarrRequestService = Depends(get_lidarr_request_service), +) -> LidarrRequestAccepted: + return await service.request_track( + album_mbid=body.album_mbid, + track_mbid=body.track_mbid, + artist_mbid=body.artist_mbid, + track_position=body.track_position, + disc_number=body.disc_number, + track_title=body.track_title, + ) + + +@router.get("/status", response_model=LidarrRequestStatusResponse) +async def get_lidarr_request_status( + album_mbid: str = Query(..., description="Album MusicBrainz release-group ID"), + service: LidarrRequestService = Depends(get_lidarr_request_service), +) -> LidarrRequestStatusResponse: + return await service.get_status(album_mbid) diff --git a/backend/api/v1/routes/stream.py b/backend/api/v1/routes/stream.py index 2e1a097..0b30eb9 100644 --- a/backend/api/v1/routes/stream.py +++ b/backend/api/v1/routes/stream.py @@ -10,12 +10,48 @@ from api.v1.schemas.stream import ( StopReportRequest, ) from core.dependencies import ( + get_cache, get_jellyfin_playback_service, get_local_files_service, get_navidrome_playback_service, get_plex_playback_service, ) from core.exceptions import ExternalServiceError, PlaybackNotAllowedError, ResourceNotFoundError + +# Cache prefixes cleared on stream 404. Must include the upstream Lidarr +# caches too — clearing only source_resolution leaves album-details + tracks +# cached for 5 more min, so the next resolve re-fetches but still gets stale +# Lidarr data back. Mirrors _DOWNLOAD_COMPLETE_CACHE_PREFIXES in +# services/track_download_service.py. +_SELF_HEAL_CACHE_PREFIXES = ( + "source_resolution", + "lidarr_album_details:", + "lidarr_album_tracks:", + "lidarr_album_trackfiles_raw:", + "lidarr_artist_albums:", + "lidarr_artist_details:", +) + + +async def _invalidate_resolve_cache_on_404(track_id: int) -> None: + """Best-effort: when stream returns 404 due to a stale Lidarr track_file_id + or a path-on-disk mismatch, clear all the caches whose stale state could + keep replaying the same wrong answer. Next /resolve-tracks call hits Lidarr + fresh. Self-healing — user only sees the 404 once per affected album.""" + try: + cache = get_cache() + total = 0 + for prefix in _SELF_HEAL_CACHE_PREFIXES: + try: + total += await cache.clear_prefix(prefix) + except Exception: # noqa: BLE001, S110 + pass + logger.warning( + "stream/local/%d 404 → self-healed: cleared %d cache entries across %d prefixes", + track_id, total, len(_SELF_HEAL_CACHE_PREFIXES), + ) + except Exception as e: # noqa: BLE001 + logger.debug("cache self-heal failed: %s", e) from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute from services.jellyfin_playback_service import JellyfinPlaybackService from services.local_files_service import LocalFilesService @@ -168,8 +204,14 @@ async def stream_local_file( media_type=headers.get("Content-Type", "application/octet-stream"), ) except ResourceNotFoundError: + # Lidarr renumbers track_file_ids on rescans/imports. Invalidate the + # source_resolution cache so the next /resolve-tracks gets fresh IDs. + await _invalidate_resolve_cache_on_404(track_id) raise HTTPException(status_code=404, detail="Track file not found") except FileNotFoundError: + # Lidarr has a track_file record but its path doesn't exist on disk + # (drive swap residue, manual file delete, etc.). Same fix: bust cache. + await _invalidate_resolve_cache_on_404(track_id) raise HTTPException(status_code=404, detail="Track file not found on disk") except PermissionError: raise HTTPException(status_code=403, detail="Access denied: path is outside the music directory") diff --git a/backend/api/v1/routes/track_download.py b/backend/api/v1/routes/track_download.py new file mode 100644 index 0000000..a436898 --- /dev/null +++ b/backend/api/v1/routes/track_download.py @@ -0,0 +1,60 @@ +"""Inline track-download API. Fork-only addition. + +Proxies to the yt-dlp-worker sidecar on gnat. The library label is fixed by +backend env (MUSICSEERR_LIBRARY) — clients cannot override it. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from api.v1.schemas.track_download import ( + TrackDownloadAccepted, + TrackDownloadJobStatus, + TrackDownloadRequest, + TrackDownloadSearchRequest, + TrackDownloadSearchResponse, +) +from core.dependencies import get_track_download_service +from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute +from services.track_download_service import TrackDownloadService + +router = APIRouter( + route_class=MsgSpecRoute, + prefix="/track-download", + tags=["track-download"], +) + + +@router.post("/search", response_model=TrackDownloadSearchResponse) +async def search_candidates( + body: TrackDownloadSearchRequest = MsgSpecBody(TrackDownloadSearchRequest), + service: TrackDownloadService = Depends(get_track_download_service), +) -> TrackDownloadSearchResponse: + return await service.search(query=body.query, limit=body.limit, source=body.source) + + +@router.post("", response_model=TrackDownloadAccepted, status_code=202) +async def request_track_download( + body: TrackDownloadRequest = MsgSpecBody(TrackDownloadRequest), + service: TrackDownloadService = Depends(get_track_download_service), +) -> TrackDownloadAccepted: + return await service.request_download( + video_id=body.video_id, + source=body.source, + target_duration_seconds=body.target_duration_seconds, + artist=body.artist, + album=body.album, + track_title=body.track_title, + artist_mbid=body.artist_mbid, + track_position=body.track_position, + disc_number=body.disc_number, + ) + + +@router.get("/{job_id}", response_model=TrackDownloadJobStatus) +async def get_track_download_job( + job_id: str, + service: TrackDownloadService = Depends(get_track_download_service), +) -> TrackDownloadJobStatus: + return await service.get_job(job_id) diff --git a/backend/api/v1/schemas/lidarr_request.py b/backend/api/v1/schemas/lidarr_request.py new file mode 100644 index 0000000..1a2244b --- /dev/null +++ b/backend/api/v1/schemas/lidarr_request.py @@ -0,0 +1,96 @@ +"""Schemas for the inline single-track Lidarr request feature. + +Fork-only addition. Distinct from track_download.py (which proxies to +the yt-dlp-worker on gnat for YouTube/Spotify-resolved downloads). + +This feature uses Lidarr's native indexer + download-client pipeline +(slskd / qBittorrent / etc.) to grab the requested track, leveraging +the track-monitored fork's PUT /track/monitor endpoint so that only +the requested track ends up on disk — sibling tracks in the same album +release are rejected at import by TrackMonitoredSpecification. + +Requires lidarr_url to point at an instance running shaunrd0/Lidarr +(currently only lidarr-shared on gnat:8688). +""" + +from __future__ import annotations + +from infrastructure.msgspec_fastapi import AppStruct + + +class LidarrRequestRequest(AppStruct): + """Inbound request from the frontend. + + album_mbid and track_mbid are required — they drive Lidarr's album + lookup/add and the per-track identification. + + artist_mbid is optional and only used for diagnostic logging; Lidarr's + album lookup already returns the artist MBID server-side and that's + what we actually use for the add. Frontend callers without artist + context (TopSongsList, etc.) can leave it null. + """ + + album_mbid: str + track_mbid: str + artist_mbid: str | None = None + # Fallback for matching when track_mbid alone doesn't disambiguate. + # Backend matcher tries them in order: foreignTrackId == track_mbid, + # foreignRecordingId == track_mbid, position+disc, then track_title. + # Frontend should send what it has — Popular Songs lists often lack + # position/disc, album-detail pages have all four. + track_position: int | None = None + disc_number: int | None = None + track_title: str | None = None + + +class LidarrRequestAccepted(AppStruct): + """Returned 202 from the request endpoint. + + `command_id` is Lidarr's command queue ID for the AlbumSearch — clients + can poll `/api/v1/command/{id}` against Lidarr directly if they want + progress, but for now the UI just shows success/error and lets the + download appear in the library when Lidarr finishes. + """ + + status: str + album_id: int + album_title: str + track_id: int + track_title: str + other_tracks_unmonitored: int + command_id: int | None = None + note: str | None = None + + +class LidarrRequestTrackStatus(AppStruct): + """Per-track status surfaced by GET /api/v1/lidarr-request/status. + + The frontend uses this to render the LidarrRequestButton in the right + persistent state — checkmark for downloaded, hourglass for requested- + but-not-yet-downloaded, idle for not-requested. Without this, the + button only knows transient session state and forgets after refresh. + + position + disc_number are returned alongside recording_mbid because + Lidarr's foreignRecordingId doesn't always equal MusicBrainz's + recording_id (Lidarr sometimes maps to a different variant). The + frontend prefers recording_mbid match but falls back to position+disc + when recording_mbid lookup misses. + """ + + recording_mbid: str + position: int + disc_number: int + monitored: bool + has_file: bool + + +class LidarrRequestStatusResponse(AppStruct): + """Returned by GET /api/v1/lidarr-request/status?album_mbid=X. + + in_library = album exists in Lidarr at all. If false, no tracks are + requested (the button shows idle on every row). If true, walk `tracks` + to find the per-track status keyed by recording_mbid. + """ + + in_library: bool + tracks: list[LidarrRequestTrackStatus] diff --git a/backend/api/v1/schemas/settings.py b/backend/api/v1/schemas/settings.py index 013fec0..e9904f5 100644 --- a/backend/api/v1/schemas/settings.py +++ b/backend/api/v1/schemas/settings.py @@ -62,10 +62,45 @@ class LastFmAuthSessionResponse(AppStruct): username: str = "" +class TrackButtonVisibility(AppStruct): + """Per-context visibility flags for the track-row action cluster. + + Each flag is a force-off: when False, the corresponding button is + suppressed even if its underlying source is configured. When True, + the existing source-availability gate applies (e.g., the Jellyfin + button still only shows when a jellyfin server is configured and + the track is mapped to a file there). + + Default all True — preserves pre-fork behavior, so users with no + `download_options` key in config.json see no change after upgrade. + + The same shape is reused for both the Popular Songs row (which today + only renders `lidarr_request` and `track_download`) and the Album + page row (which renders the full cluster). Carrying all flags in + both contexts means a future expansion to e.g. show Plex playback + next to Popular Songs needs no schema migration. + """ + + lidarr_request: bool = True + track_download: bool = True + preview: bool = True + yt_play: bool = True + jellyfin: bool = True + local_files: bool = True + navidrome: bool = True + plex: bool = True + + +class DownloadOptions(AppStruct): + popular_songs: TrackButtonVisibility = msgspec.field(default_factory=TrackButtonVisibility) + album_page: TrackButtonVisibility = msgspec.field(default_factory=TrackButtonVisibility) + + class UserPreferences(AppStruct): primary_types: list[str] = msgspec.field(default_factory=lambda: ["album", "ep", "single"]) secondary_types: list[str] = msgspec.field(default_factory=lambda: ["studio"]) release_statuses: list[str] = msgspec.field(default_factory=lambda: ["official"]) + download_options: DownloadOptions = msgspec.field(default_factory=DownloadOptions) class LidarrConnectionSettings(AppStruct): @@ -171,18 +206,26 @@ class HomeSettings(AppStruct): cache_ttl_personal: int = 300 show_whats_hot: bool = True show_globally_trending: bool = True - - def __post_init__(self) -> None: - if self.cache_ttl_trending < 300 or self.cache_ttl_trending > 86400: - raise msgspec.ValidationError("cache_ttl_trending must be between 300 and 86400") - if self.cache_ttl_personal < 60 or self.cache_ttl_personal > 3600: - raise msgspec.ValidationError("cache_ttl_personal must be between 60 and 3600") + # Defaults False because Plex /status/sessions returns ALL active audio + # streams across the whole server with no library-section filter — on a + # shared instance that means anyone hitting the UI sees what every other + # household member is listening to. Local MusicSeerr playback (the user's + # own tab) is unaffected; this only gates the server-derived feed used + # by HomeSectionNowPlaying, SidebarVisualiser, and the /library/* pages. + show_now_playing: bool = False class LocalFilesConnectionSettings(AppStruct): enabled: bool = False music_path: str = "/music" - lidarr_root_path: str = "/music" + # Lidarr's container-internal root path — the prefix musicseerr strips + # from Lidarr-returned track paths before joining with music_path. Must + # match Lidarr's /data convention (LSIO + hotio *arr images all mount + # /data); the upstream default of /music was wrong for any deployment + # that pairs musicseerr with a real Lidarr instance. Symptom of the + # wrong value: /api/v1/stream/local/ returns 404 because the remap + # produces /music/data//... which doesn't exist. + lidarr_root_path: str = "/data" class LocalFilesVerifyResponse(AppStruct): diff --git a/backend/api/v1/schemas/track_download.py b/backend/api/v1/schemas/track_download.py new file mode 100644 index 0000000..49c4204 --- /dev/null +++ b/backend/api/v1/schemas/track_download.py @@ -0,0 +1,78 @@ +"""Schemas for the inline track-download feature. + +This is a fork-only addition. Requests are proxied to a yt-dlp-worker sidecar +on gnat which performs the actual yt-dlp call and post-download Lidarr/Plex +triggers. The library label (music | music-personal) is stamped server-side +from the MUSICSEERR_LIBRARY env var so that public musicseerr cannot drop +files into the personal library. + +Search source: "youtube" (default; free-text yt-dlp search) or "spotify" +(Spotify Web API search; the worker resolves the chosen Spotify track to a +matching YouTube video at download time, transparently to the client). +""" + +from __future__ import annotations + +from typing import Literal + +from infrastructure.msgspec_fastapi import AppStruct + + +SearchSource = Literal["youtube", "spotify"] + + +class TrackDownloadSearchRequest(AppStruct): + query: str + limit: int = 5 + source: SearchSource = "youtube" + + +class TrackDownloadCandidate(AppStruct): + # video_id is a YouTube video ID when source="youtube" and a Spotify + # track ID when source="spotify". The worker disambiguates by source. + video_id: str + url: str + title: str + source: SearchSource = "youtube" + channel: str | None = None + artist: str | None = None # populated for source="spotify" + album: str | None = None # populated for source="spotify" + duration_seconds: int | None = None + thumbnail_url: str | None = None + + +class TrackDownloadSearchResponse(AppStruct): + candidates: list[TrackDownloadCandidate] + + +class TrackDownloadRequest(AppStruct): + """Inbound request from the frontend. The library param is intentionally + NOT included here — the backend stamps it from env config so the public + musicseerr instance cannot target the personal library.""" + + video_id: str + artist: str + album: str + track_title: str + source: SearchSource = "youtube" + target_duration_seconds: int | None = None # passed through for spotify→yt resolution + artist_mbid: str | None = None + track_position: int | None = None + disc_number: int | None = None + + +class TrackDownloadAccepted(AppStruct): + job_id: str + + +class TrackDownloadJobStatus(AppStruct): + id: str + status: str + artist: str + album: str + track_title: str + library: str + created_at: str + updated_at: str + file_path: str | None = None + error: str | None = None diff --git a/backend/cache/.gitignore-check b/backend/cache/.gitignore-check deleted file mode 100644 index e69de29..0000000 diff --git a/backend/core/config.py b/backend/core/config.py index 11db643..265f317 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -66,6 +66,52 @@ class Settings(BaseSettings): audiodb_api_key: str = Field(default="123") audiodb_premium: bool = Field(default=False, description="Set to true if using a premium AudioDB API key") instance_id: str = Field(default="", description="Auto-generated per-instance UUID for User-Agent differentiation") + + # Inline track-download feature (fork-only). Stamped per musicseerr instance via env: + # public musicseerr -> "music"; admin musicseerr-personal -> "music-personal"; + # public musicseerr-shared -> "music-shared". Backend rejects any other value + # and the client cannot override it. + musicseerr_library: str = Field( + default="music", + description="Target library for track downloads: 'music', 'music-personal', or 'music-shared'.", + ) + yt_dlp_worker_url: str = Field( + default="http://yt-dlp-worker:4949", + description="Base URL for the yt-dlp-worker sidecar that performs single-track downloads.", + ) + + # Plex notification on download-complete (fork-only). When all three are set, + # the track-download flow fires `/library/sections//refresh` after each + # successful download so Plex picks up the new file immediately. Per-instance: + # musicseerr -> section 3, musicseerr-personal -> 6, musicseerr-shared -> 7. + # Empty = feature disabled (download still works, Plex just won't auto-scan). + plex_url: str = Field( + default="", + description="Plex base URL for post-download library-section refresh. Empty disables the integration.", + ) + plex_token: str = Field( + default="", + description="Plex authentication token (X-Plex-Token header).", + ) + plex_section_id: int = Field( + default=0, + description="Plex library section ID matching this musicseerr's library (3/6/7 in our deployment).", + ) + + @field_validator("musicseerr_library") + @classmethod + def validate_musicseerr_library(cls, v: str) -> str: + normalised = v.strip().lower() + if normalised not in {"music", "music-personal", "music-shared"}: + raise ValueError( + f"musicseerr_library must be 'music', 'music-personal', or 'music-shared'; got '{v}'" + ) + return normalised + + @field_validator("yt_dlp_worker_url") + @classmethod + def validate_worker_url(cls, v: str) -> str: + return v.rstrip("/") @field_validator("log_level") @classmethod diff --git a/backend/core/dependencies/__init__.py b/backend/core/dependencies/__init__.py index 33a79dd..9634717 100644 --- a/backend/core/dependencies/__init__.py +++ b/backend/core/dependencies/__init__.py @@ -70,6 +70,8 @@ from .service_providers import ( # noqa: F401 get_plex_library_service, get_plex_playback_service, get_version_service, + get_track_download_service, + get_lidarr_request_service, ) from .type_aliases import ( # noqa: F401 @@ -120,6 +122,7 @@ from .type_aliases import ( # noqa: F401 CacheStatusServiceDep, GitHubRepositoryDep, VersionServiceDep, + TrackDownloadServiceDep, ) from .cleanup import ( # noqa: F401 diff --git a/backend/core/dependencies/service_providers.py b/backend/core/dependencies/service_providers.py index 285f8f7..e9aafdf 100644 --- a/backend/core/dependencies/service_providers.py +++ b/backend/core/dependencies/service_providers.py @@ -657,3 +657,27 @@ def get_version_service() -> "VersionService": github_repo = get_github_repository() return VersionService(github_repo) + + +@singleton +def get_track_download_service() -> "TrackDownloadService": + from core.config import get_settings + from services.track_download_service import TrackDownloadService + + settings = get_settings() + return TrackDownloadService( + worker_url=settings.yt_dlp_worker_url, + library=settings.musicseerr_library, + lidarr_repository=get_lidarr_repository(), + memory_cache=get_cache(), + plex_url=settings.plex_url, + plex_token=settings.plex_token, + plex_section_id=settings.plex_section_id, + ) + + +@singleton +def get_lidarr_request_service() -> "LidarrRequestService": + from services.lidarr_request_service import LidarrRequestService + + return LidarrRequestService(lidarr_repository=get_lidarr_repository()) diff --git a/backend/core/dependencies/type_aliases.py b/backend/core/dependencies/type_aliases.py index 0ad4cc3..42f51cb 100644 --- a/backend/core/dependencies/type_aliases.py +++ b/backend/core/dependencies/type_aliases.py @@ -53,6 +53,7 @@ from services.lastfm_auth_service import LastFmAuthService from services.scrobble_service import ScrobbleService from services.cache_status_service import CacheStatusService from services.version_service import VersionService +from services.track_download_service import TrackDownloadService from .cache_providers import ( get_cache, @@ -105,6 +106,7 @@ from .service_providers import ( get_plex_library_service, get_plex_playback_service, get_version_service, + get_track_download_service, ) @@ -155,3 +157,4 @@ PlexPlaybackServiceDep = Annotated[PlexPlaybackService, Depends(get_plex_playbac CacheStatusServiceDep = Annotated[CacheStatusService, Depends(get_cache_status_service)] GitHubRepositoryDep = Annotated[GitHubRepository, Depends(get_github_repository)] VersionServiceDep = Annotated[VersionService, Depends(get_version_service)] +TrackDownloadServiceDep = Annotated[TrackDownloadService, Depends(get_track_download_service)] diff --git a/backend/main.py b/backend/main.py index 85985fb..f4349d0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -52,6 +52,8 @@ from api.v1.routes import plex_library as plex_library_routes from api.v1.routes import plex_auth as plex_auth_routes from api.v1.routes import version as version_routes from api.v1.routes import download as download_routes +from api.v1.routes import track_download as track_download_routes +from api.v1.routes import lidarr_request as lidarr_request_routes logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) @@ -289,6 +291,9 @@ app.add_middleware( "/api/v1/search": (10.0, 20), "/api/v1/discover": (10.0, 20), "/api/v1/covers": (15.0, 30), + # No track-download override — middleware matches by prefix and would + # drain the bucket via the polling GETs. The actual rate limit is the + # worker's serial queue on gnat. }, ) app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=6) @@ -344,6 +349,8 @@ v1_router.include_router(profile.router) v1_router.include_router(playlists.router) v1_router.include_router(version_routes.router) v1_router.include_router(download_routes.router) +v1_router.include_router(track_download_routes.router) +v1_router.include_router(lidarr_request_routes.router) app.include_router(v1_router) mount_frontend(app) diff --git a/backend/repositories/lidarr/album.py b/backend/repositories/lidarr/album.py index 811cbed..94c2ec0 100644 --- a/backend/repositories/lidarr/album.py +++ b/backend/repositories/lidarr/album.py @@ -315,6 +315,109 @@ class LidarrAlbumRepository(LidarrHistoryRepository): logger.error(f"Failed to delete album {album_id}: {e}") raise + async def get_album_tracks_raw(self, album_id: int) -> list[dict[str, Any]]: + """Return Lidarr's raw /track response for an album. + + Distinct from get_album_tracks (which projects to a simplified + UI-friendly shape and drops foreignTrackId / foreignRecordingId / id). + Callers that need MBIDs or the Lidarr internal track id (e.g., to + flip track monitor state) want this method. + + Returns tracks for the currently-monitored release. For the full + cross-release set use get_album_tracks_raw_by_release per release. + """ + try: + data = await self._get("/api/v1/track", params={"albumId": album_id}) + return data if isinstance(data, list) else [] + except Exception as e: # noqa: BLE001 + logger.error("get_album_tracks_raw failed for album %d: %s", album_id, e) + return [] + + async def get_album_tracks_raw_by_release( + self, album_release_id: int + ) -> list[dict[str, Any]]: + """Return raw tracks for a specific AlbumRelease. + + Lidarr's /track endpoint accepts albumReleaseId as a filter; this + is the only way to see tracks on a non-monitored release (the + albumId filter only returns the active release's tracks). + """ + try: + data = await self._get( + "/api/v1/track", params={"albumReleaseId": album_release_id} + ) + return data if isinstance(data, list) else [] + except Exception as e: # noqa: BLE001 + logger.error( + "get_album_tracks_raw_by_release failed for release %d: %s", + album_release_id, e, + ) + return [] + + async def wait_for_album_tracks_raw( + self, album_id: int, timeout_s: float = 30.0, poll_s: float = 1.0 + ) -> list[dict[str, Any]]: + """Poll get_album_tracks_raw until Lidarr has populated the track list. + + Same shape as wait_for_album_tracks but returns the raw track payload + so callers can access foreignTrackId / foreignRecordingId / id. + """ + deadline = time.monotonic() + timeout_s + tracks: list[dict[str, Any]] = [] + while time.monotonic() < deadline: + tracks = await self.get_album_tracks_raw(album_id) + if tracks: + return tracks + await asyncio.sleep(poll_s) + return tracks + + async def set_track_monitored(self, track_ids: list[int], monitored: bool) -> bool: + """PUT /track/monitor — fork-only endpoint on shaunrd0/Lidarr. + + Stock Lidarr nightly returns 405 (endpoint doesn't exist). Callers + are expected to ensure their LIDARR_URL points at a fork instance + (currently lidarr-shared on gnat:8688). + """ + if not track_ids: + return True + try: + await self._put( + "/api/v1/track/monitor", + {"trackIds": track_ids, "monitored": monitored}, + ) + return True + except Exception as e: # noqa: BLE001 + logger.error("Failed to set track monitored for %d tracks: %s", len(track_ids), e) + return False + + async def wait_for_album_tracks( + self, album_id: int, timeout_s: float = 30.0, poll_s: float = 1.0 + ) -> list[dict[str, Any]]: + """Poll get_album_tracks until Lidarr has populated the track list. + + After an album add, Lidarr fetches track metadata from MusicBrainz + asynchronously. The track list shows up within a few seconds for + most albums; we poll with a short backoff so the caller doesn't + need to busy-wait or guess. + """ + # Invalidate the in-process cache once so the first poll fetches fresh. + # The cache is set with a 300s TTL inside get_album_tracks itself, and + # without this an empty-on-first-call result would be cached and we'd + # never see the real tracks until the TTL expired. + cache_key = f"{LIDARR_ALBUM_TRACKS_PREFIX}{album_id}" + await self._cache.delete(cache_key) + + deadline = time.monotonic() + timeout_s + tracks: list[dict[str, Any]] = [] + while time.monotonic() < deadline: + tracks = await self.get_album_tracks(album_id) + if tracks: + return tracks + # Same invalidation reason as above for subsequent polls. + await self._cache.delete(cache_key) + await asyncio.sleep(poll_s) + return tracks + async def set_monitored(self, album_mbid: str, monitored: bool) -> bool: lidarr_album = await self._get_album_by_foreign_id(album_mbid) if not lidarr_album: @@ -329,7 +432,20 @@ class LidarrAlbumRepository(LidarrHistoryRepository): await self._invalidate_album_list_caches() return True - async def add_album(self, musicbrainz_id: str, artist_repo) -> dict: + async def add_album( + self, + musicbrainz_id: str, + artist_repo, + search_after_add: bool = True, + ) -> dict: + """Add an album to Lidarr. + + search_after_add (default True): whether to fire AlbumSearch as part + of the add. The single-track Lidarr request flow needs this False so + it can unmonitor sibling tracks BEFORE Lidarr's auto-search races + the import past the track-monitored gate. All other callers (UI add, + playlist import, etc.) keep the existing default-true behavior. + """ t0 = time.monotonic() if not musicbrainz_id or not isinstance(musicbrainz_id, str): raise ExternalServiceError("Invalid MBID provided") @@ -361,6 +477,7 @@ class LidarrAlbumRepository(LidarrHistoryRepository): musicbrainz_id, artist_repo, t0, candidate, album_title, album_type, secondary_types, artist_mbid, artist_name, + search_after_add=search_after_add, ) async def _add_album_locked( @@ -374,6 +491,7 @@ class LidarrAlbumRepository(LidarrHistoryRepository): secondary_types: list, artist_mbid: str, artist_name: str | None, + search_after_add: bool = True, ) -> dict: # Capture which albums are already monitored so we can revert any Lidarr auto-monitors after the add pre_add_monitored_ids: set[int] = set() @@ -414,10 +532,11 @@ class LidarrAlbumRepository(LidarrHistoryRepository): if not is_monitored: album_obj = await self._update_album(album_id, {"monitored": True}) - try: - await self._post_command({"name": "AlbumSearch", "albumIds": [album_id]}) - except ExternalServiceError: - pass + if search_after_add: + try: + await self._post_command({"name": "AlbumSearch", "albumIds": [album_id]}) + except ExternalServiceError: + pass await self._unmonitor_auto_monitored_albums( artist_id, musicbrainz_id, album_id, pre_add_monitored_ids @@ -474,7 +593,7 @@ class LidarrAlbumRepository(LidarrHistoryRepository): "anyReleaseOk": True, "profileId": profile_id, "images": [], - "addOptions": {"addType": "automatic", "searchForNewAlbum": True}, + "addOptions": {"addType": "automatic", "searchForNewAlbum": search_after_add}, } try: @@ -487,12 +606,13 @@ class LidarrAlbumRepository(LidarrHistoryRepository): if album_obj: if not album_obj.get("monitored"): album_obj = await self._update_album(album_obj["id"], {"monitored": True}) - try: - await self._post_command( - {"name": "AlbumSearch", "albumIds": [album_obj["id"]]} - ) - except ExternalServiceError: - pass + if search_after_add: + try: + await self._post_command( + {"name": "AlbumSearch", "albumIds": [album_obj["id"]]} + ) + except ExternalServiceError: + pass elif "post failed" in err_str or "405" in err_str or "metadata" in err_str: raise ExternalServiceError( f"Lidarr rejected '{album_title}' ({album_type}" @@ -519,10 +639,11 @@ class LidarrAlbumRepository(LidarrHistoryRepository): set_artist_monitored=artist_created, ) - try: - await self._post_command({"name": "AlbumSearch", "albumIds": [album_id]}) - except ExternalServiceError: - pass + if search_after_add: + try: + await self._post_command({"name": "AlbumSearch", "albumIds": [album_id]}) + except ExternalServiceError: + pass # Unmonitor albums that Lidarr auto-monitored during the add await self._unmonitor_auto_monitored_albums( diff --git a/backend/repositories/lidarr/artist.py b/backend/repositories/lidarr/artist.py index adc8ce8..d1b4878 100644 --- a/backend/repositories/lidarr/artist.py +++ b/backend/repositories/lidarr/artist.py @@ -192,6 +192,37 @@ class LidarrArtistRepository(LidarrBase): except Exception as e: # noqa: BLE001 return None + async def trigger_refresh_by_mbid(self, artist_mbid: str) -> int | None: + """Fire RefreshArtist for the artist matching `artist_mbid` (best effort, + does NOT wait for completion). Returns the Lidarr command id on success. + + Used by the track-download flow to scope a rescan to JUST the artist + whose file was just written, rather than a full RescanFolders. Failures + are swallowed — this is an enhancement, not a critical-path call. + """ + try: + items = await self._get("/api/v1/artist", params={"mbId": artist_mbid}) + if not items or not isinstance(items, list): + logger.debug("trigger_refresh_by_mbid: no Lidarr artist for mbid=%s", artist_mbid) + return None + artist_id = items[0].get("id") + if not artist_id: + return None + cmd = await self._post_command( + {"name": "RefreshArtist", "artistId": artist_id} + ) + cmd_id = cmd.get("id") if isinstance(cmd, dict) else None + logger.info( + "trigger_refresh_by_mbid: fired RefreshArtist artist_id=%s mbid=%s cmd_id=%s", + artist_id, artist_mbid, cmd_id, + ) + return cmd_id + except Exception as e: # noqa: BLE001 + logger.warning( + "trigger_refresh_by_mbid failed for mbid=%s: %s", artist_mbid, e + ) + return None + async def delete_artist(self, artist_id: int, delete_files: bool = False) -> bool: try: params = {"deleteFiles": str(delete_files).lower(), "addImportListExclusion": "false"} diff --git a/backend/repositories/lidarr/repository.py b/backend/repositories/lidarr/repository.py index 33de61f..98c3c1e 100644 --- a/backend/repositories/lidarr/repository.py +++ b/backend/repositories/lidarr/repository.py @@ -32,5 +32,7 @@ class LidarrRepository( super().__init__(settings, http_client, cache) self._request_history_store = request_history_store - async def add_album(self, musicbrainz_id: str) -> dict: - return await LidarrAlbumRepository.add_album(self, musicbrainz_id, self) + async def add_album(self, musicbrainz_id: str, search_after_add: bool = True) -> dict: + return await LidarrAlbumRepository.add_album( + self, musicbrainz_id, self, search_after_add=search_after_add + ) diff --git a/backend/services/library_service.py b/backend/services/library_service.py index 13300ce..93ca67b 100644 --- a/backend/services/library_service.py +++ b/backend/services/library_service.py @@ -702,7 +702,12 @@ class LibraryService: except Exception: # noqa: BLE001 logger.debug("Jellyfin track resolution failed for %s", album_mbid, exc_info=True) - await self._memory_cache.set(cache_key, result, ttl_seconds=3600) + # Short TTL — Lidarr's track-file IDs change on every rescan + import, + # so a long cache holds stale IDs whose stream attempts then 404. With + # the download-complete fan-out invalidating the cache on real changes + # PLUS the stream endpoint self-healing on 404 (see stream.py), 60s + # is enough to absorb burst traffic without holding stale data. + await self._memory_cache.set(cache_key, result, ttl_seconds=60) return result async def resolve_tracks_batch( diff --git a/backend/services/lidarr_request_service.py b/backend/services/lidarr_request_service.py new file mode 100644 index 0000000..136965c --- /dev/null +++ b/backend/services/lidarr_request_service.py @@ -0,0 +1,483 @@ +"""LidarrRequestService — orchestrates a single-track Lidarr request. + +Fork-only addition, sibling to TrackDownloadService (which proxies to +yt-dlp-worker). Where TrackDownloadService grabs a single audio file +via yt-dlp, this service uses Lidarr's full indexer + download-client +pipeline to grab the track from a release the user has configured +indexers for (slskd, qBittorrent, NZBGet, etc.). + +The flow only works end-to-end when LIDARR_URL points at an instance +running the track-monitored fork (shaunrd0/Lidarr) — the PUT +/track/monitor endpoint that this service relies on returns 405 on +stock Lidarr. set_track_monitored() in the repo logs + returns False +in that case so the request still completes (album gets added, search +fires), but the user will see siblings on the album re-download too. + +Race-condition handling: Lidarr's "add album" normally fires AlbumSearch +immediately. That races our unmonitor — if the grab + import complete +before we flip monitor flags, the fork's TrackMonitoredSpecification +sees all-monitored tracks and accepts the full release. We pass +search_after_add=False so Lidarr does NOT auto-search, then we set +monitor flags across ALL releases (not just the currently-active one, +since Lidarr's anyReleaseOk=true means the grab may match a release +we didn't touch), then we trigger AlbumSearch ourselves. +""" + +from __future__ import annotations + +import asyncio +import collections +import logging +from typing import TYPE_CHECKING + +from api.v1.schemas.lidarr_request import ( + LidarrRequestAccepted, + LidarrRequestStatusResponse, + LidarrRequestTrackStatus, +) +from core.exceptions import ( + ExternalServiceError, + ResourceNotFoundError, + ValidationError, +) + +if TYPE_CHECKING: + from repositories.lidarr import LidarrRepository + +logger = logging.getLogger(__name__) + + +# Per-album-MBID asyncio locks. Rapid clicks on N tracks of the SAME album +# serialize through the album's lock so each request sees the prior one's +# monitor flips before deciding its own unmonitor strategy. Different +# albums run in parallel as before. +# +# Bounded LRU to prevent unbounded growth — the existing per-artist lock +# pattern in repositories/lidarr/album.py uses the same shape. +_MAX_ALBUM_LOCKS = 64 +_album_locks: "collections.OrderedDict[str, asyncio.Lock]" = collections.OrderedDict() + + +def _get_album_lock(album_mbid: str) -> asyncio.Lock: + if album_mbid in _album_locks: + _album_locks.move_to_end(album_mbid) + return _album_locks[album_mbid] + lock = asyncio.Lock() + _album_locks[album_mbid] = lock + # Best-effort eviction of an old unlocked entry when we exceed the cap. + while len(_album_locks) > _MAX_ALBUM_LOCKS: + for key in list(_album_locks.keys()): + if not _album_locks[key].locked(): + del _album_locks[key] + break + else: + break + return lock + + +class LidarrRequestService: + """Coordinate Lidarr add + selective monitor + search for a single track.""" + + def __init__(self, *, lidarr_repository: "LidarrRepository") -> None: + self._lidarr = lidarr_repository + + async def request_track( + self, + *, + album_mbid: str, + track_mbid: str, + artist_mbid: str | None = None, + track_position: int | None = None, + disc_number: int | None = None, + track_title: str | None = None, + ) -> LidarrRequestAccepted: + # artist_mbid is optional — Lidarr's album lookup returns the artist + # MBID server-side, so we only need album + track for the API flow. + if not (album_mbid and track_mbid): + raise ValidationError("album_mbid and track_mbid are required") + + # Per-album serialization. Rapid clicks on 3 tracks from the same + # album each acquire this lock in turn. Without it, all 3 requests + # would race through get_album_by_mbid before any of them had a + # chance to flip monitor flags, so all 3 would think they were the + # "fresh add" and each would unmonitor every track that wasn't + # their own target — leaving only the last-completed request's + # track monitored. + async with _get_album_lock(album_mbid): + return await self._request_track_locked( + album_mbid=album_mbid, + track_mbid=track_mbid, + track_position=track_position, + disc_number=disc_number, + track_title=track_title, + ) + + async def get_status(self, album_mbid: str) -> LidarrRequestStatusResponse: + """Return per-track Lidarr-side status for an album. + + Lets the UI render the LidarrRequestButton in its persistent state + (idle / requested / downloaded) on page load instead of resetting + to idle on every refresh. Cheap — one /album?foreignAlbumId= and + one /track?albumId= per call. No mutations. + + IMPORTANT: a track is reported as `monitored=true` ONLY when the + full Lidarr chain agrees the user wants it — track + album + artist + all monitored. Lidarr's search/grab query filters on all three, so + a track with `track.Monitored=true` but `album.Monitored=false` + will never actually get downloaded. Reporting just the track flag + was misleading: an album auto-added through some other code path + (recommendations, ListenBrainz, manual artist add) inherits the + fork's Track.Monitored=true default on all its tracks, but with + album/artist unmonitored Lidarr ignores them entirely — the UI + would falsely render every track as "requested" even though the + user never asked for them. + + has_file is independent — if the file is on disk it's on disk + regardless of monitor state. + """ + if not album_mbid: + raise ValidationError("album_mbid is required") + + album = await self._lidarr.get_album_by_mbid(album_mbid) + if not album or not album.get("id"): + return LidarrRequestStatusResponse(in_library=False, tracks=[]) + + album_monitored = bool(album.get("monitored", False)) + artist_monitored = bool((album.get("artist") or {}).get("monitored", False)) + chain_monitored = album_monitored and artist_monitored + + # Only the active release's tracks matter for the UI — the user + # only sees one release per album page anyway. + tracks = await self._lidarr.get_album_tracks_raw(album["id"]) + out: list[LidarrRequestTrackStatus] = [] + for t in tracks: + recording_mbid = t.get("foreignRecordingId") or "" + try: + position = int(t.get("trackNumber") or t.get("absoluteTrackNumber") or 0) + except (TypeError, ValueError): + position = 0 + try: + disc_number = int(t.get("mediumNumber") or 1) + except (TypeError, ValueError): + disc_number = 1 + track_flag = bool(t.get("monitored", False)) + out.append( + LidarrRequestTrackStatus( + recording_mbid=recording_mbid, + position=position, + disc_number=disc_number, + # AND the chain — see docstring above. + monitored=track_flag and chain_monitored, + has_file=bool(t.get("hasFile", False)), + ) + ) + return LidarrRequestStatusResponse(in_library=True, tracks=out) + + async def _request_track_locked( + self, + *, + album_mbid: str, + track_mbid: str, + track_position: int | None, + disc_number: int | None, + track_title: str | None, + ) -> LidarrRequestAccepted: + # 1. Ensure album exists in Lidarr WITHOUT triggering Lidarr's auto- + # search-on-add (which would race our unmonitor below). add_album + # returns a wrapper dict; we re-fetch by MBID for a consistent shape. + # + # `was_added_now` controls the unmonitor strategy below. On a fresh + # add every track defaults to monitored=true, so we have to unmonitor + # siblings or Lidarr will download the whole album. If the album + # already existed, the user (or a prior request) has already set the + # monitor state — we preserve it and just add OUR target to the + # monitored set, so 3 rapid clicks on Tracks A/B/C end with all + # three monitored, not just whichever click landed last. + album = await self._lidarr.get_album_by_mbid(album_mbid) + was_added_now = album is None + if was_added_now: + logger.info("lidarr-request: album %s not in Lidarr, adding (no auto-search)", album_mbid) + try: + await self._lidarr.add_album(album_mbid, search_after_add=False) + except ExternalServiceError as e: + raise ExternalServiceError(f"Lidarr add_album failed: {e}") from e + album = await self._lidarr.get_album_by_mbid(album_mbid) + + if not album or not album.get("id"): + raise ExternalServiceError( + f"Album {album_mbid} could not be added or found in Lidarr after add" + ) + + album_id = album["id"] + album_title = album.get("title", "Unknown") + + # Ensure the artist is monitored. Lidarr's wanted/missing query (and + # the scheduled MissingAlbumSearch) filter out tracks whose artist + # is unmonitored, even if the tracks themselves are monitored — so + # if a prior interaction unmonitored this artist, every track we + # request would show as REQUESTED in our UI but Lidarr would never + # actually search for it on its own cadence. (Our explicit + # trigger_album_search below still fires once, but if that grab + # doesn't land, no retry ever happens.) + artist = album.get("artist") or {} + artist_mbid_from_album = ( + artist.get("foreignArtistId") or artist.get("mbId") + ) + if artist_mbid_from_album and not artist.get("monitored"): + try: + logger.info( + "lidarr-request[album %d]: monitoring artist %s (was unmonitored)", + album_id, artist_mbid_from_album, + ) + await self._lidarr.update_artist_monitoring( + artist_mbid_from_album, monitored=True, monitor_new_items="none", + ) + except Exception as e: # noqa: BLE001 + logger.warning( + "lidarr-request[album %d]: failed to monitor artist %s: %s", + album_id, artist_mbid_from_album, e, + ) + + # 2. Gather ALL tracks across every AlbumRelease. Lidarr's + # anyReleaseOk=true means a grab can match any release of the + # album, so we need to flip monitor flags on tracks in ALL + # releases or the import for an unintended release sneaks past + # our gate. The collector polls for releases since Lidarr + # populates the releases array async after add. + all_tracks = await self._collect_tracks_across_releases(album_id) + if not all_tracks: + raise ExternalServiceError( + f"Lidarr did not populate tracks for album {album_id} within timeout" + ) + + # 3. Identify the target track on the *currently-monitored* release + # (for the response payload). For the unmonitor step we treat ALL + # release-instances of this recording as "target." + active_release_tracks = [ + t for t in all_tracks if t.get("_release_monitored") + ] or all_tracks + target = _find_track( + active_release_tracks, track_mbid, track_position, disc_number, track_title, + ) + if not target: + raise ResourceNotFoundError( + f"Track {track_mbid} (pos={track_position}, disc={disc_number}, " + f"title={track_title!r}) not found on Lidarr album {album_id} " + f"({album_title})" + ) + + target_id = target["id"] + target_title = target.get("title", "Unknown") + + # Match siblings vs targets across ALL releases using the same fuzzy + # chain that resolved the active-release target above. Matching by + # foreignRecordingId equality alone (the old behavior) misses songs + # whose MB recording UUID differs across releases of the same album + # — common for tracks released as singles with their own MB + # recording entry distinct from the album recording. Hit us on + # Gorillaz Demon Days 2026-05-29: the user's 7 requested tracks + # were correctly monitored on the 23-track Bootleg (the active + # release at request time), but only 5 carried over to the + # 15-track Official that Lidarr later picked on import — Kids With + # Guns and Feel Good Inc. each have a single-specific recording + # UUID, so they silently dropped out of the monitored set when the + # release switched. Re-running _find_track per release with the + # user's original request data (track_mbid + position + disc + + # title) catches them via the position+disc or title fallback even + # when foreignRecordingId equality fails. + tracks_by_release: dict[int | None, list[dict]] = collections.defaultdict(list) + for t in all_tracks: + tracks_by_release[t.get("_release_id")].append(t) + + target_ids: set[int] = set() + for rel_tracks in tracks_by_release.values(): + match = _find_track( + rel_tracks, track_mbid, track_position, disc_number, track_title, + ) + if match and match.get("id"): + target_ids.add(match["id"]) + + # Defensive floor: if for some reason no cross-release match + # resolved, at least monitor the active-release target we already + # found. Shouldn't normally happen since _find_track succeeded on + # the active release just above. + if not target_ids: + target_ids = {target_id} + + sibling_ids = [ + t["id"] for t in all_tracks + if t.get("id") and t["id"] not in target_ids + ] + + # 4. Set monitor flags. + # - was_added_now=True (fresh add, everything defaults monitored=true): + # unmonitor siblings so they don't get downloaded. + # - was_added_now=False (album was already in Lidarr): leave existing + # monitor state alone — preserves any tracks the user (or prior + # rapid clicks on this album) had set monitored. We just add OUR + # target to the monitored set on top of whatever was there. + unmonitored_ok = True + unmonitored_count = 0 + if was_added_now and sibling_ids: + unmonitored_ok = await self._lidarr.set_track_monitored(sibling_ids, monitored=False) + unmonitored_count = len(sibling_ids) if unmonitored_ok else 0 + + if target_ids: + await self._lidarr.set_track_monitored(list(target_ids), monitored=True) + + # 5. NOW trigger AlbumSearch — flags are in place, fork's import + # specification will reject siblings regardless of which release the + # grab matches. + command = await self._lidarr.trigger_album_search([album_id]) + command_id = command.get("id") if command else None + + note = None + if was_added_now and sibling_ids and not unmonitored_ok: + note = ( + f"track unmonitor skipped (PUT /track/monitor returned non-OK; " + f"Lidarr instance is likely not running the track-monitored fork). " + f"Search will still fire but {len(sibling_ids)} sibling track(s) " + f"on this album may also get downloaded." + ) + logger.warning("lidarr-request[album %d]: %s", album_id, note) + elif not was_added_now: + note = ( + f"album already in Lidarr; preserved existing monitor state " + f"and added '{target_title}' to the monitored set" + ) + + return LidarrRequestAccepted( + status="accepted", + album_id=album_id, + album_title=album_title, + track_id=target_id, + track_title=target_title, + other_tracks_unmonitored=unmonitored_count, + command_id=command_id, + note=note, + ) + + async def _collect_tracks_across_releases(self, album_id: int) -> list[dict]: + """Fetch raw tracks for every AlbumRelease of an album. + + Returns a flat list of track dicts, each annotated with + ``_release_monitored: bool`` so the caller can identify which + tracks belong to the currently-active release. + + Polls /album/{id} until the `releases` array is populated — + Lidarr fetches it async after add and a too-early read returns + an empty list. Then iterates every release, including the active + one, fetching raw tracks per-release via /track?albumReleaseId=. + Using the per-release filter (rather than albumId) avoids + depending on which release Lidarr currently flags as active. + """ + import asyncio + + # Poll for releases — Lidarr populates them async after add. + deadline = __import__("time").monotonic() + 30.0 + album_full = None + releases: list[dict] = [] + while __import__("time").monotonic() < deadline: + album_full = await self._lidarr.get_album_by_id(album_id) + releases = (album_full or {}).get("releases") or [] + if releases: + break + await asyncio.sleep(1.0) + + if not releases: + # Fallback: at least try the active-release-only fetch so the + # caller still has something to identify the target against. + logger.warning( + "lidarr-request[album %d]: no releases populated within timeout; " + "falling back to active-release-only track fetch", + album_id, + ) + active_only = await self._lidarr.wait_for_album_tracks_raw( + album_id, timeout_s=30.0 + ) + # _release_id=None in fallback — caller groups tracks by it, + # so a single None bucket Just Works. + return [ + {**t, "_release_monitored": True, "_release_id": None} + for t in active_only + ] + + out: list[dict] = [] + for release in releases: + release_id = release.get("id") + if not release_id: + continue + release_tracks = await self._lidarr.get_album_tracks_raw_by_release(release_id) + release_monitored = bool(release.get("monitored")) + # Annotate _release_id so callers can group tracks by their + # owning release for per-release matching (see target_ids + # computation in _request_track_locked). + out.extend( + { + **t, + "_release_monitored": release_monitored, + "_release_id": release_id, + } + for t in release_tracks + ) + + if not out: + logger.warning( + "lidarr-request[album %d]: all %d releases returned 0 tracks", + album_id, len(releases), + ) + + return out + + +def _find_track( + tracks: list[dict], + track_mbid: str, + track_position: int | None, + disc_number: int | None, + track_title: str | None, +) -> dict | None: + """Resolve a target track within Lidarr's track list for an album. + + Lidarr's foreignRecordingId doesn't always equal MusicBrainz's + recording_id (Lidarr sometimes stores a release-specific variant). + Popular Songs lists from Last.fm also don't always have position/disc + populated. So we try four strategies in priority order; the first + unambiguous match wins. + + Match priority: + 1. foreignTrackId == track_mbid (the MB track id, when sent) + 2. foreignRecordingId == track_mbid (the MB recording id) + 3. position + disc fallback (when both provided and exactly one match) + 4. title fallback — exact-then-substring, case-insensitive + """ + for t in tracks: + if t.get("foreignTrackId") == track_mbid: + return t + for t in tracks: + if t.get("foreignRecordingId") == track_mbid: + return t + if track_position is not None and disc_number is not None: + matches = [ + t for t in tracks + if t.get("mediumNumber") == disc_number + and str(t.get("trackNumber", "")) == str(track_position) + ] + if len(matches) == 1: + return matches[0] + if track_title: + needle = track_title.strip().lower() + # Exact case-insensitive first. + exact = [t for t in tracks if (t.get("title") or "").strip().lower() == needle] + if len(exact) == 1: + return exact[0] + # Then substring (handles "Drive My Car" vs "Drive My Car (Remastered 2009)" + # and similar reissue annotations). Only commit if exactly one match. + substr = [ + t for t in tracks + if needle in (t.get("title") or "").strip().lower() + or (t.get("title") or "").strip().lower() in needle + ] + if len(substr) == 1: + return substr[0] + return None diff --git a/backend/services/track_download_service.py b/backend/services/track_download_service.py new file mode 100644 index 0000000..eebcd72 --- /dev/null +++ b/backend/services/track_download_service.py @@ -0,0 +1,323 @@ +"""TrackDownloadService — proxies inline track-download requests to the +yt-dlp-worker sidecar (on gnat). Library label is stamped from env config +so the public musicseerr instance cannot reach Music-Personal. + +Also fires a scoped Lidarr RefreshArtist the FIRST TIME a job status flips +to "done" — so the new file gets picked up immediately instead of waiting +for Lidarr's next scheduled scan (which could be hours). Without this, the +file lands on disk but Lidarr's DB doesn't know about it, and the resolve +endpoint may either miss it (track not playable) or worse — return a stale +track_file_id whose path no longer exists (we hit this 2026-05-25 after a +drive swap). + +Fork-only addition; do not entangle with services/youtube_service.py +(which uses the YouTube Data API and is subject to upstream rebase). +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, TYPE_CHECKING + +import httpx + +from api.v1.schemas.track_download import ( + SearchSource, + TrackDownloadAccepted, + TrackDownloadCandidate, + TrackDownloadJobStatus, + TrackDownloadSearchResponse, +) +from core.exceptions import ExternalServiceError, ResourceNotFoundError, ValidationError + +if TYPE_CHECKING: + from repositories.lidarr import LidarrRepository + from infrastructure.cache.memory_cache import CacheInterface + +logger = logging.getLogger(__name__) + + +VALID_LIBRARIES = frozenset({"music", "music-personal", "music-shared"}) + +# Cache prefixes invalidated on download completion. Mirrors constants in +# infrastructure/cache/cache_keys.py (kept as string literals here to avoid a +# circular import at this layer). +# +# Need ALL of these — clearing only source_resolution leaves upstream Lidarr +# album/track caches (5 min TTL) stale. Resolve re-fetches but then calls +# get_album_details which still returns hasFile=false from the Lidarr cache, +# so the new track keeps showing "not in library" until those TTLs expire. +_DOWNLOAD_COMPLETE_CACHE_PREFIXES = ( + "source_resolution", + "lidarr_album_details:", + "lidarr_album_tracks:", + "lidarr_album_trackfiles_raw:", + "lidarr_artist_albums:", + "lidarr_artist_details:", +) + + +class TrackDownloadService: + """Thin async proxy to the yt-dlp-worker. The library label is fixed + at construction time and applied to every download request. + + On first-seen status=done for any job, fans out three best-effort + actions so the new file is immediately discoverable end-to-end: + 1. Lidarr RefreshArtist (per-artist DB refresh + disk scan) + 2. Plex library/sections//refresh (so Plex indexes the file) + 3. memory_cache.clear_prefix(source_resolution) (so musicseerr's + resolve endpoint doesn't return stale "not in library" data + from before the file landed) + + Any single piece can fail without breaking the others — download + success is reported regardless of post-download fan-out outcomes. + """ + + def __init__( + self, + *, + worker_url: str, + library: str, + lidarr_repository: "LidarrRepository | None" = None, + memory_cache: "CacheInterface | None" = None, + plex_url: str = "", + plex_token: str = "", + plex_section_id: int = 0, + timeout: float = 30.0, + ) -> None: + if library not in VALID_LIBRARIES: + raise ValueError( + f"MUSICSEERR_LIBRARY must be one of {sorted(VALID_LIBRARIES)}; got '{library}'" + ) + self._worker_url = worker_url.rstrip("/") + self._library = library + self._timeout = timeout + self._lidarr = lidarr_repository + self._memory_cache = memory_cache + self._plex_url = plex_url.rstrip("/") + self._plex_token = plex_token + self._plex_section_id = plex_section_id + # job_id → artist_mbid, captured at request time so we can fire a + # scoped RefreshArtist when the poll later sees status="done". + # In-memory only — if musicseerr restarts mid-download the rescan + # for that job is just skipped (best-effort enhancement). + self._mbid_by_job: dict[str, str] = {} + # job_ids we've already fired RefreshArtist for; prevents the same + # rescan firing every poll after completion (frontend keeps polling + # for a few cycles after status=done to confirm the final state). + self._rescan_fired: set[str] = set() + + @property + def library(self) -> str: + return self._library + + async def search( + self, + query: str, + limit: int = 5, + source: SearchSource = "youtube", + ) -> TrackDownloadSearchResponse: + payload = {"query": query, "limit": limit, "source": source} + data = await self._post_json("/search", payload) + candidates = [ + TrackDownloadCandidate( + video_id=c["video_id"], + url=c["url"], + title=c["title"], + source=c.get("source", "youtube"), + channel=c.get("channel"), + artist=c.get("artist"), + album=c.get("album"), + duration_seconds=c.get("duration_seconds"), + thumbnail_url=c.get("thumbnail_url"), + ) + for c in data.get("candidates", []) + ] + return TrackDownloadSearchResponse(candidates=candidates) + + async def request_download( + self, + *, + video_id: str, + artist: str, + album: str, + track_title: str, + source: SearchSource = "youtube", + target_duration_seconds: int | None = None, + artist_mbid: str | None, + track_position: int | None, + disc_number: int | None, + user_id: str | None = None, + ) -> TrackDownloadAccepted: + payload: dict[str, Any] = { + "video_id": video_id, + "source": source, + "target_duration_seconds": target_duration_seconds, + "artist": artist, + "album": album, + "track_title": track_title, + "artist_mbid": artist_mbid, + "track_position": track_position, + "disc_number": disc_number, + "library": self._library, + "user_id": user_id, + } + data = await self._post_json("/download", payload, expected_status={200, 202}) + job_id = data["job_id"] + # Stash the mbid so we can fire a scoped Lidarr rescan on completion. + # mbid is optional — only useful if we have a real MusicBrainz id. + if artist_mbid: + self._mbid_by_job[job_id] = artist_mbid + return TrackDownloadAccepted(job_id=job_id) + + async def get_job(self, job_id: str) -> TrackDownloadJobStatus: + data = await self._get_json(f"/jobs/{job_id}") + status = data["status"] + + # First-seen transition to "done" → fan out three best-effort actions + # asynchronously so the new file is discoverable end-to-end without + # waiting on any one of them. Each is fire-and-forget; failures in + # one don't block the others or affect the download status returned + # to the client. + if status == "done" and job_id not in self._rescan_fired: + self._rescan_fired.add(job_id) + mbid = self._mbid_by_job.pop(job_id, None) + asyncio.create_task(self._on_download_complete(job_id, mbid)) + + return TrackDownloadJobStatus( + id=data["id"], + status=status, + artist=data["artist"], + album=data["album"], + track_title=data["track_title"], + library=data["library"], + file_path=data.get("file_path"), + error=data.get("error"), + created_at=data["created_at"], + updated_at=data["updated_at"], + ) + + async def _on_download_complete(self, job_id: str, mbid: str | None) -> None: + """Three-step post-download fan-out. Best-effort; any single failure + is logged but doesn't propagate (the download itself already succeeded). + """ + # 1. Lidarr per-artist refresh (so Lidarr DB sees the file) + if mbid and self._lidarr is not None: + try: + logger.info( + "on_download_complete[%s]: fire Lidarr RefreshArtist mbid=%s", + job_id, mbid, + ) + await self._lidarr.trigger_refresh_by_mbid(mbid) + except Exception as e: # noqa: BLE001 + logger.warning( + "on_download_complete[%s]: Lidarr refresh failed: %s", job_id, e + ) + else: + logger.debug( + "on_download_complete[%s]: Lidarr refresh skipped (mbid=%s lidarr=%s)", + job_id, bool(mbid), bool(self._lidarr), + ) + + # 2. Plex section refresh (so Plex indexes the new file → Plexamp can play it) + if self._plex_url and self._plex_token and self._plex_section_id: + try: + logger.info( + "on_download_complete[%s]: fire Plex refresh section=%d", + job_id, self._plex_section_id, + ) + async with httpx.AsyncClient(timeout=10.0) as client: + r = await client.get( + f"{self._plex_url}/library/sections/{self._plex_section_id}/refresh", + headers={"X-Plex-Token": self._plex_token}, + ) + if r.status_code >= 300: + logger.warning( + "on_download_complete[%s]: Plex refresh returned HTTP %d", + job_id, r.status_code, + ) + except Exception as e: # noqa: BLE001 + logger.warning( + "on_download_complete[%s]: Plex refresh failed: %s", job_id, e + ) + else: + logger.debug( + "on_download_complete[%s]: Plex refresh skipped (not configured)", + job_id, + ) + + # 3. Invalidate musicseerr's caches — clears stale "track not in library" + # answers AND the upstream Lidarr album/track caches that would otherwise + # be queried after the source_resolution miss and just return stale data + # for another 5 minutes. Over-eager: clears the entire prefix per layer, + # not just this artist's album. Safe — caches rehydrate on demand. + if self._memory_cache is not None: + total = 0 + for prefix in _DOWNLOAD_COMPLETE_CACHE_PREFIXES: + try: + n = await self._memory_cache.clear_prefix(prefix) + total += n + except Exception as e: # noqa: BLE001 + logger.warning( + "on_download_complete[%s]: cache invalidate failed for prefix %s: %s", + job_id, prefix, e, + ) + logger.info( + "on_download_complete[%s]: cleared %d cache entries across %d prefixes", + job_id, total, len(_DOWNLOAD_COMPLETE_CACHE_PREFIXES), + ) + + # ---- internal HTTP helpers ---- + + async def _post_json( + self, + path: str, + payload: dict[str, Any], + *, + expected_status: set[int] | None = None, + ) -> dict[str, Any]: + url = f"{self._worker_url}{path}" + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: + r = await client.post(url, json=payload) + except httpx.HTTPError as e: + logger.error("yt-dlp-worker POST %s failed: %s", path, e) + raise ExternalServiceError( + f"yt-dlp-worker unreachable: {e}" + ) from e + return self._handle_response(r, path, expected_status) + + async def _get_json(self, path: str) -> dict[str, Any]: + url = f"{self._worker_url}{path}" + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: + r = await client.get(url) + except httpx.HTTPError as e: + logger.error("yt-dlp-worker GET %s failed: %s", path, e) + raise ExternalServiceError( + f"yt-dlp-worker unreachable: {e}" + ) from e + return self._handle_response(r, path) + + @staticmethod + def _handle_response( + r: httpx.Response, + path: str, + expected_status: set[int] | None = None, + ) -> dict[str, Any]: + if r.status_code == 404: + raise ResourceNotFoundError(f"yt-dlp-worker {path}: not found") + if r.status_code == 422: + raise ValidationError(f"yt-dlp-worker {path}: invalid request: {r.text[:200]}") + if r.status_code == 429: + raise ValidationError("Rate limit exceeded; try again shortly") + if expected_status is not None and r.status_code not in expected_status: + raise ExternalServiceError( + f"yt-dlp-worker {path}: unexpected status {r.status_code}: {r.text[:200]}" + ) + if expected_status is None and (r.status_code < 200 or r.status_code >= 300): + raise ExternalServiceError( + f"yt-dlp-worker {path}: unexpected status {r.status_code}: {r.text[:200]}" + ) + return r.json() diff --git a/backend/tests/schemas/test_home_settings_schema.py b/backend/tests/schemas/test_home_settings_schema.py new file mode 100644 index 0000000..199ab28 --- /dev/null +++ b/backend/tests/schemas/test_home_settings_schema.py @@ -0,0 +1,16 @@ +from api.v1.schemas.settings import HomeSettings + + +class TestHomeSettingsDefaults: + def test_show_now_playing_defaults_off(self) -> None: + # Default off: Plex /status/sessions returns sessions across the whole + # server with no library-section filter, so a shared instance leaks + # every household member's listening activity. The privacy default + # must be off — opt-in per instance. + assert HomeSettings().show_now_playing is False + + def test_show_whats_hot_default_unchanged(self) -> None: + assert HomeSettings().show_whats_hot is True + + def test_show_globally_trending_default_unchanged(self) -> None: + assert HomeSettings().show_globally_trending is True diff --git a/backend/tests/services/test_lidarr_request_service.py b/backend/tests/services/test_lidarr_request_service.py new file mode 100644 index 0000000..f6938a3 --- /dev/null +++ b/backend/tests/services/test_lidarr_request_service.py @@ -0,0 +1,203 @@ +"""Tests for LidarrRequestService — the single-track request orchestrator. + +Regression-focused. The cross-release matching test reproduces the 2026-05-29 +Gorillaz Demon Days bug: the user requested 7 specific tracks via +musicseerr-shared, Lidarr later switched the active release from the +23-track Bootleg (relId=9981) to a 15-track Official (relId=9989), and only +5/7 tracks remained monitored on the active release. + +Root cause: `_request_track_locked` was matching siblings-vs-targets across +all releases by `foreignRecordingId` only. But the "same song" on different +releases of the same album frequently has DIFFERENT MusicBrainz recording +IDs (album version vs single mix vs deluxe remaster), so the matcher missed +those releases entirely. Kids With Guns and Feel Good Inc. happen to be +exactly that case — singles with their own recording UUIDs distinct from +the album recording on the bootleg. + +Fix: run the same fuzzy `_find_track` chain per release using the user's +original request data (track_mbid + position + disc + title) instead of +relying on equality of release-specific recording IDs. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from services.lidarr_request_service import LidarrRequestService, _find_track + + +def _track( + track_id: int, + title: str, + position: int, + *, + disc: int = 1, + recording_id: str | None = None, + track_id_mb: str | None = None, + monitored: bool = False, +) -> dict: + return { + "id": track_id, + "title": title, + "trackNumber": str(position), + "absoluteTrackNumber": position, + "mediumNumber": disc, + "foreignTrackId": track_id_mb or f"trk-{track_id}", + "foreignRecordingId": recording_id or f"rec-{track_id}", + "monitored": monitored, + } + + +def _make_repo(*, releases: list[dict], tracks_by_release: dict[int, list[dict]]) -> MagicMock: + repo = MagicMock() + # Album exists on first lookup (the "was_added_now=False" path — exercises + # the additive-monitor branch, no sibling unmonitor). + repo.get_album_by_mbid = AsyncMock( + return_value={ + "id": 1916, + "title": "Demon Days", + "monitored": True, + "artist": {"foreignArtistId": "ARTIST_MBID", "monitored": True}, + } + ) + repo.get_album_by_id = AsyncMock(return_value={"id": 1916, "releases": releases}) + repo.get_album_tracks_raw_by_release = AsyncMock( + side_effect=lambda rid: list(tracks_by_release.get(rid, [])) + ) + repo.set_track_monitored = AsyncMock(return_value=True) + repo.trigger_album_search = AsyncMock(return_value={"id": 42}) + repo.update_artist_monitoring = AsyncMock() + repo.add_album = AsyncMock() + return repo + + +@pytest.mark.asyncio +async def test_cross_release_match_uses_position_disc_title_when_recording_ids_diverge(): + # Mirrors the real Gorillaz Demon Days case: one user-requested track + # ("Feel Good Inc.") at the same position+disc on three releases but + # with a DIFFERENT foreignRecordingId on each release (since MB has + # separate recordings for the album version vs single vs remaster). + target_active = _track(1001, "Feel Good Inc.", 6, recording_id="rec-active") + target_release_b = _track(1002, "Feel Good Inc.", 6, recording_id="rec-bootleg") + target_release_c = _track(1003, "Feel Good Inc.", 6, recording_id="rec-official") + + intro_active = _track(2001, "Intro", 1, recording_id="rec-intro-active") + intro_b = _track(2002, "Intro", 1, recording_id="rec-intro-b") + intro_c = _track(2003, "Intro", 1, recording_id="rec-intro-c") + + repo = _make_repo( + releases=[ + {"id": 9989, "monitored": True}, + {"id": 9981, "monitored": False}, + {"id": 9985, "monitored": False}, + ], + tracks_by_release={ + 9989: [target_active, intro_active], + 9981: [target_release_b, intro_b], + 9985: [target_release_c, intro_c], + }, + ) + + service = LidarrRequestService(lidarr_repository=repo) + + result = await service.request_track( + album_mbid="ALBUM_MBID", + # The frontend sends the active-release foreignTrackId here. Other + # releases have different track ids, so id-only matching would miss + # them — the matcher must fall through to position+disc+title. + track_mbid="trk-1001", + track_position=6, + disc_number=1, + track_title="Feel Good Inc.", + ) + + assert result.status == "accepted" + assert result.track_id == 1001 # target on active release + + # All three Feel Good Inc. ids must have been set monitored=True. + # Before the fix, only 1001 (active-release recording id match) would + # have been included. + monitored_call_ids: set[int] = set() + for call in repo.set_track_monitored.await_args_list: + if call.kwargs.get("monitored") is True or ( + len(call.args) >= 2 and call.args[1] is True + ): + ids = call.args[0] + for i in ids: + monitored_call_ids.add(i) + + assert {1001, 1002, 1003}.issubset(monitored_call_ids), ( + f"target tracks across all three releases should be monitored, " + f"got {monitored_call_ids}" + ) + + # And the siblings (Intro on each release) must NOT be in the monitored + # set. (Verifies we didn't accidentally monitor too much.) + assert not ({2001, 2002, 2003} & monitored_call_ids), ( + f"sibling Intro tracks should not be monitored, " + f"got {monitored_call_ids & {2001, 2002, 2003}}" + ) + + +@pytest.mark.asyncio +async def test_cross_release_match_still_uses_recording_id_when_available(): + # When releases DO share the same recording id (the common case for + # album tracks like "Last Living Souls" on Demon Days), the matcher + # should obviously still catch them. Same shape as above but all three + # share recording id "rec-shared". + t_active = _track(1001, "Last Living Souls", 2, recording_id="rec-shared") + t_b = _track(1002, "Last Living Souls", 2, recording_id="rec-shared") + t_c = _track(1003, "Last Living Souls", 2, recording_id="rec-shared") + + repo = _make_repo( + releases=[ + {"id": 9989, "monitored": True}, + {"id": 9981, "monitored": False}, + {"id": 9985, "monitored": False}, + ], + tracks_by_release={9989: [t_active], 9981: [t_b], 9985: [t_c]}, + ) + service = LidarrRequestService(lidarr_repository=repo) + + await service.request_track( + album_mbid="ALBUM_MBID", + track_mbid="trk-1001", + track_position=2, + disc_number=1, + track_title="Last Living Souls", + ) + + monitored_ids: set[int] = set() + for call in repo.set_track_monitored.await_args_list: + if call.kwargs.get("monitored") is True or ( + len(call.args) >= 2 and call.args[1] is True + ): + for i in call.args[0]: + monitored_ids.add(i) + assert {1001, 1002, 1003}.issubset(monitored_ids) + + +def test_find_track_falls_through_to_position_disc_when_ids_dont_match(): + # Direct test on the matcher: when the user-provided track_mbid does + # NOT equal any track's foreignTrackId OR foreignRecordingId, the + # position+disc fallback should still resolve to the right track. + tracks = [ + _track(1, "Intro", 1), + _track(2, "Feel Good Inc.", 6, recording_id="rec-X"), + _track(3, "DARE", 12), + ] + found = _find_track(tracks, "UNRELATED-MBID", 6, 1, "Feel Good Inc.") + assert found is tracks[1] + + +def test_find_track_falls_through_to_title_when_position_disc_missing(): + # And when position/disc aren't sent at all (e.g., Popular Songs + # panel), the title-match fallback should still find it. + tracks = [ + _track(1, "Intro", 1), + _track(2, "Feel Good Inc.", 6, recording_id="rec-X"), + ] + found = _find_track(tracks, "UNRELATED-MBID", None, None, "Feel Good Inc.") + assert found is tracks[1] diff --git a/backend/tests/test_dependencies_package.py b/backend/tests/test_dependencies_package.py index 5287f7a..becc056 100644 --- a/backend/tests/test_dependencies_package.py +++ b/backend/tests/test_dependencies_package.py @@ -9,7 +9,11 @@ from core.dependencies._registry import _singleton_registry, clear_all_singleton class TestSingletonRegistry: def test_registry_has_expected_count(self): - assert len(_singleton_registry) == 57 + # Bumped from 57 to 59 for the fork-added singletons: + # get_track_download_service (yt-dlp proxy) + # get_lidarr_request_service (single-track Lidarr request flow) + # Update this number whenever you add/remove an @singleton. + assert len(_singleton_registry) == 59 def test_all_entries_have_cache_clear(self): for fn in _singleton_registry: diff --git a/backend/tests/test_local_files_fallback.py b/backend/tests/test_local_files_fallback.py index c95148f..077db6a 100644 --- a/backend/tests/test_local_files_fallback.py +++ b/backend/tests/test_local_files_fallback.py @@ -15,7 +15,7 @@ def _make_local_files_service(lidarr=None, cache=None): cache_ttl_local_files_storage_stats=300, ) prefs.get_local_files_connection.return_value = MagicMock( - music_path="/music", lidarr_root_path="/music" + music_path="/music", lidarr_root_path="/data" ) cache = cache or AsyncMock() return LocalFilesService( diff --git a/frontend/src/lib/api/lidarrRequest.ts b/frontend/src/lib/api/lidarrRequest.ts new file mode 100644 index 0000000..617d26e --- /dev/null +++ b/frontend/src/lib/api/lidarrRequest.ts @@ -0,0 +1,104 @@ +import { api } from '$lib/api/client'; + +export interface LidarrRequestPayload { + album_mbid: string; + track_mbid: string; + artist_mbid?: string | null; + track_position?: number | null; + disc_number?: number | null; + track_title?: string | null; +} + +export interface LidarrRequestAccepted { + status: string; + album_id: number; + album_title: string; + track_id: number; + track_title: string; + other_tracks_unmonitored: number; + command_id: number | null; + note: string | null; +} + +export interface LidarrRequestTrackStatus { + recording_mbid: string; + position: number; + disc_number: number; + monitored: boolean; + has_file: boolean; +} + +export interface LidarrRequestStatusResponse { + in_library: boolean; + tracks: LidarrRequestTrackStatus[]; +} + +/** The 3 persistent UI states the LidarrRequestButton can render. */ +export type LidarrButtonStatus = 'none' | 'requested' | 'downloaded'; + +/** Project a single Lidarr per-track entry into a UI-friendly status. */ +export function projectButtonStatus( + t: LidarrRequestTrackStatus | undefined | null +): LidarrButtonStatus { + if (!t) return 'none'; + if (t.has_file) return 'downloaded'; + if (t.monitored) return 'requested'; + return 'none'; +} + +/** + * Build a status lookup over a status response. + * + * Indexes by both recording_mbid AND `albumMbid:position:disc` because + * Lidarr's foreignRecordingId doesn't always equal MusicBrainz's + * recording_id (Lidarr sometimes maps to a variant). The album_mbid is + * baked into the position key as a safety belt so a stale lookup from + * a previous album page can't false-positive-match the visible album's + * tracks at the same positions (positions 1, 2, 3… collide trivially + * across every album in existence otherwise). + */ +export function buildStatusLookup(albumMbid: string, res: LidarrRequestStatusResponse) { + const byMbid = new Map(); + const byPositionDisc = new Map(); + for (const t of res.tracks) { + if (t.recording_mbid) byMbid.set(t.recording_mbid, t); + byPositionDisc.set(`${albumMbid}:${t.position}:${t.disc_number}`, t); + } + return { + albumMbid, + byMbid, + byPositionDisc, + lookup( + lookupAlbumMbid: string, + recordingMbid: string | null | undefined, + position: number, + discNumber: number + ) { + if (recordingMbid && byMbid.has(recordingMbid)) return byMbid.get(recordingMbid); + // Album-scope the position fallback — won't match tracks from a + // different album even if the caller is somehow using a stale + // lookup built for that other album. + if (lookupAlbumMbid !== albumMbid) return undefined; + return byPositionDisc.get(`${albumMbid}:${position}:${discNumber}`); + } + }; +} + +const ROOT = '/api/v1/lidarr-request'; + +export async function requestTrackViaLidarr( + payload: LidarrRequestPayload, + signal?: AbortSignal +): Promise { + return api.global.post(ROOT, payload, { signal }); +} + +export async function getLidarrRequestStatus( + albumMbid: string, + signal?: AbortSignal +): Promise { + return api.global.get( + `${ROOT}/status?album_mbid=${encodeURIComponent(albumMbid)}`, + { signal } + ); +} diff --git a/frontend/src/lib/api/trackDownload.ts b/frontend/src/lib/api/trackDownload.ts new file mode 100644 index 0000000..b1b7bfc --- /dev/null +++ b/frontend/src/lib/api/trackDownload.ts @@ -0,0 +1,91 @@ +import { api } from '$lib/api/client'; + +export type TrackDownloadStatus = + | 'queued' + | 'searching' + | 'downloading' + | 'tagging' + | 'importing' + | 'done' + | 'failed'; + +export type TrackDownloadSource = 'youtube' | 'spotify'; + +export const TRACK_DOWNLOAD_TERMINAL_STATES: ReadonlySet = new Set([ + 'done', + 'failed' +]); + +export interface TrackDownloadCandidate { + video_id: string; + url: string; + title: string; + source: TrackDownloadSource; + channel: string | null; + artist: string | null; + album: string | null; + duration_seconds: number | null; + thumbnail_url: string | null; +} + +export interface TrackDownloadSearchResponse { + candidates: TrackDownloadCandidate[]; +} + +export interface TrackDownloadRequestPayload { + video_id: string; + artist: string; + album: string; + track_title: string; + source?: TrackDownloadSource; + target_duration_seconds?: number | null; + artist_mbid?: string | null; + track_position?: number | null; + disc_number?: number | null; +} + +export interface TrackDownloadAccepted { + job_id: string; +} + +export interface TrackDownloadJobStatus { + id: string; + status: TrackDownloadStatus; + artist: string; + album: string; + track_title: string; + library: string; + created_at: string; + updated_at: string; + file_path: string | null; + error: string | null; +} + +const ROOT = '/api/v1/track-download'; + +export async function searchTrackCandidates( + query: string, + limit = 5, + source: TrackDownloadSource = 'youtube', + signal?: AbortSignal +): Promise { + return api.global.post( + `${ROOT}/search`, + { query, limit, source }, + { signal } + ); +} + +export async function requestTrackDownload( + payload: TrackDownloadRequestPayload, + signal?: AbortSignal +): Promise { + return api.global.post(ROOT, payload, { signal }); +} + +export async function getTrackDownloadJob( + jobId: string, + signal?: AbortSignal +): Promise { + return api.global.get(`${ROOT}/${jobId}`, { signal }); +} diff --git a/frontend/src/lib/components/LidarrRequestButton.svelte b/frontend/src/lib/components/LidarrRequestButton.svelte new file mode 100644 index 0000000..dbb463c --- /dev/null +++ b/frontend/src/lib/components/LidarrRequestButton.svelte @@ -0,0 +1,167 @@ + + + diff --git a/frontend/src/lib/components/TopSongsList.svelte b/frontend/src/lib/components/TopSongsList.svelte index 067b59d..461c2d5 100644 --- a/frontend/src/lib/components/TopSongsList.svelte +++ b/frontend/src/lib/components/TopSongsList.svelte @@ -4,8 +4,14 @@ import { API } from '$lib/constants'; import { api } from '$lib/api/client'; import { playerStore } from '$lib/stores/player.svelte'; + import { libraryRefresh } from '$lib/stores/libraryRefresh.svelte'; import TrackRow from './TrackRow.svelte'; import { SvelteMap } from 'svelte/reactivity'; + import { + getLidarrRequestStatus, + projectButtonStatus, + type LidarrButtonStatus + } from '$lib/api/lidarrRequest'; interface Props { songs: TopSong[]; @@ -25,8 +31,16 @@ let cacheMap = new SvelteMap(); let resolveMap = new SvelteMap(); + // Lidarr-side per-track status, keyed by recording_mbid. Built by + // fanning out a /lidarr-request/status call per unique album_mbid in + // the songs list. Used to render the LidarrRequestButton in its + // persistent state (hourglass / checkmark / idle) so songs the user + // already requested or downloaded show up that way without a refresh + // dropping them back to idle. + let lidarrStatusMap = new SvelteMap(); let lastFetchedKey = $state(''); let lastResolveKey = $state(''); + let lastLidarrStatusKey = $state(''); function cacheKey(artist: string, track: string): string { return `${artist.toLowerCase()}|${track.toLowerCase()}`; @@ -49,7 +63,10 @@ $effect(() => { if (!ytConfigured || songs.length === 0) return; - const key = songsFingerprint(songs); + // Include libraryRefresh.version in the key so the cache-check refetches + // after a successful download (TrackDownloadButton bumps the counter on + // first-seen status=done). + const key = `${libraryRefresh.version}|${songsFingerprint(songs)}`; if (key === lastFetchedKey) return; lastFetchedKey = key; @@ -75,7 +92,9 @@ $effect(() => { const resolvable = songs.filter((s) => s.release_group_mbid && s.track_number != null); if (resolvable.length === 0) return; - const key = resolvableFingerprint(songs); + // Same as above — bumping libraryRefresh forces re-resolve so newly- + // downloaded tracks flip to the "in library" play icon without a page reload. + const key = `${libraryRefresh.version}|${resolvableFingerprint(songs)}`; if (key === lastResolveKey) return; lastResolveKey = key; @@ -121,6 +140,44 @@ ); } + function lidarrStatusForSong(song: TopSong): LidarrButtonStatus { + if (!song.recording_mbid) return 'none'; + return lidarrStatusMap.get(song.recording_mbid) ?? 'none'; + } + + $effect(() => { + // Collect unique album_mbids across all songs in the visible list. + // Each one becomes one /lidarr-request/status call. For a typical + // 20-song popular list this is ~10–20 albums in parallel; cheap + // enough not to need a multi-album batch endpoint yet. + const uniqueAlbumMbids = new Set(); + for (const s of songs) { + if (s.release_group_mbid && s.recording_mbid) uniqueAlbumMbids.add(s.release_group_mbid); + } + if (uniqueAlbumMbids.size === 0) return; + + // Include libraryRefresh.version so the map refreshes after a + // successful download — a song goes from `requested` → `downloaded` + // without a page reload. + const key = `${libraryRefresh.version}|${Array.from(uniqueAlbumMbids).sort().join(';')}`; + if (key === lastLidarrStatusKey) return; + lastLidarrStatusKey = key; + + (async () => { + const responses = await Promise.allSettled( + Array.from(uniqueAlbumMbids).map((m) => getLidarrRequestStatus(m)) + ); + if (lastLidarrStatusKey !== key) return; + for (const r of responses) { + if (r.status !== 'fulfilled') continue; + for (const t of r.value.tracks) { + if (!t.recording_mbid) continue; + lidarrStatusMap.set(t.recording_mbid, projectButtonStatus(t)); + } + } + })(); + }); + function buildQueueItems(startSong: TopSong): { items: QueueItem[]; startIndex: number } { const items: QueueItem[] = []; let startIndex = 0; @@ -195,6 +252,7 @@ {ytConfigured} initialCached={cacheMap.get(cacheKey(song.artist_name, song.title)) ?? null} resolvedTrack={getResolvedTrack(song)} + lidarrStatus={lidarrStatusForSong(song)} onPlay={() => handlePlay(song)} /> {/each} diff --git a/frontend/src/lib/components/TrackDownloadButton.svelte b/frontend/src/lib/components/TrackDownloadButton.svelte new file mode 100644 index 0000000..2f5e0d8 --- /dev/null +++ b/frontend/src/lib/components/TrackDownloadButton.svelte @@ -0,0 +1,390 @@ + + + + + + + + diff --git a/frontend/src/lib/components/TrackRow.svelte b/frontend/src/lib/components/TrackRow.svelte index f394d22..495279d 100644 --- a/frontend/src/lib/components/TrackRow.svelte +++ b/frontend/src/lib/components/TrackRow.svelte @@ -1,10 +1,14 @@ {#if hasAlbum} @@ -39,13 +67,11 @@ {#if canPlay} {:else if previewEnabled} @@ -93,6 +119,40 @@

+ + +
+ {#if buttonVisibility.lidarr_request && song.recording_mbid && song.release_group_mbid} + + {/if} + {#if buttonVisibility.track_download} + + {/if} +
{:else}
- {position} - + {:else if previewEnabled} @@ -140,5 +198,22 @@

{song.title}

+ + +
+ {#if buttonVisibility.track_download} + + {/if} +
{/if} diff --git a/frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-calls-onBeforeJump-when-provided-and-a-letter-is-clicked-1.png b/frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-calls-onBeforeJump-when-provided-and-a-letter-is-clicked-1.png deleted file mode 100644 index e4e97aa..0000000 Binary files a/frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-calls-onBeforeJump-when-provided-and-a-letter-is-clicked-1.png and /dev/null differ diff --git a/frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-disables-letters-without-content-1.png b/frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-disables-letters-without-content-1.png deleted file mode 100644 index 4f90423..0000000 Binary files a/frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-disables-letters-without-content-1.png and /dev/null differ diff --git a/frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-renders-all-27-letter-buttons--A-Z------1.png b/frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-renders-all-27-letter-buttons--A-Z------1.png deleted file mode 100644 index f72190d..0000000 Binary files a/frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-renders-all-27-letter-buttons--A-Z------1.png and /dev/null differ diff --git a/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-appends--medium-suffix-for-lg-size-1.png b/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-appends--medium-suffix-for-lg-size-1.png deleted file mode 100644 index f0c706e..0000000 Binary files a/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-appends--medium-suffix-for-lg-size-1.png and /dev/null differ diff --git a/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-CDN-URL-with-referrerpolicy-when-remoteUrl-is-set-1.png b/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-CDN-URL-with-referrerpolicy-when-remoteUrl-is-set-1.png deleted file mode 100644 index 855e9ef..0000000 Binary files a/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-CDN-URL-with-referrerpolicy-when-remoteUrl-is-set-1.png and /dev/null differ diff --git a/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-proxy-URL-for-artist-when-remoteUrl-is-null-1.png b/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-proxy-URL-for-artist-when-remoteUrl-is-null-1.png deleted file mode 100644 index 37d4fad..0000000 Binary files a/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-proxy-URL-for-artist-when-remoteUrl-is-null-1.png and /dev/null differ diff --git a/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-proxy-URL-when-remoteUrl-is-null-for-artist-1.png b/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-proxy-URL-when-remoteUrl-is-null-for-artist-1.png deleted file mode 100644 index 37d4fad..0000000 Binary files a/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-proxy-URL-when-remoteUrl-is-null-for-artist-1.png and /dev/null differ diff --git a/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-uses-original-URL-for-full-size-1.png b/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-uses-original-URL-for-full-size-1.png deleted file mode 100644 index f0c706e..0000000 Binary files a/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-uses-original-URL-for-full-size-1.png and /dev/null differ diff --git a/frontend/src/lib/components/settings/SettingsHome.svelte b/frontend/src/lib/components/settings/SettingsHome.svelte index 1efaf63..15469b3 100644 --- a/frontend/src/lib/components/settings/SettingsHome.svelte +++ b/frontend/src/lib/components/settings/SettingsHome.svelte @@ -3,6 +3,8 @@ import { createSettingsForm } from '$lib/utils/settingsForm.svelte'; import { invalidateQueriesWithPersister } from '$lib/queries/QueryClient'; import { HomeQueryKeyFactory } from '$lib/queries/HomeQueryKeyFactory'; + import { homeSettingsStore } from '$lib/stores/homeSettings.svelte'; + import { nowPlayingStore } from '$lib/stores/nowPlayingSessions.svelte'; import { onMount, onDestroy } from 'svelte'; const form = createSettingsForm({ @@ -10,6 +12,12 @@ saveEndpoint: '/api/v1/settings/home', afterSave: async () => { await invalidateQueriesWithPersister({ queryKey: HomeQueryKeyFactory.prefix }); + // Refresh the cross-cutting store so nowPlayingSessions.fetchAll() + // picks up the new value on its next tick — and trigger an + // immediate poll so the banner appears/disappears without the + // user having to wait the 3s interval. + await homeSettingsStore.refresh(); + void nowPlayingStore.refresh(); } }); @@ -55,6 +63,26 @@ +
+ +
+ {#if form.message}
({ import SettingsHome from './SettingsHome.svelte'; -function mockHomeSettings(overrides: { show_whats_hot?: boolean } = {}) { +function mockHomeSettings( + overrides: { show_whats_hot?: boolean; show_now_playing?: boolean } = {} +) { return { show_whats_hot: true, show_globally_trending: true, + show_now_playing: false, cache_ttl_trending: 3600, cache_ttl_personal: 300, ...overrides @@ -110,4 +113,38 @@ describe('SettingsHome.svelte', () => { expect(toggle.checked).toBe(true); }); }); + + it('renders Show currently listening toggle', async () => { + globalThis.fetch = mockJsonResponse(mockHomeSettings()); + render(SettingsHome); + + const label = page.getByText('Show currently listening'); + await expect.element(label).toBeInTheDocument(); + }); + + it('currently-listening toggle defaults to the loaded value (off)', async () => { + globalThis.fetch = mockJsonResponse(mockHomeSettings({ show_now_playing: false })); + render(SettingsHome); + + await vi.waitFor(() => { + const toggles = document.querySelectorAll( + 'input[type="checkbox"].toggle' + ) as NodeListOf; + expect(toggles.length).toBeGreaterThanOrEqual(2); + expect(toggles[1].checked).toBe(false); + }); + }); + + it('currently-listening toggle reflects loaded state when on', async () => { + globalThis.fetch = mockJsonResponse(mockHomeSettings({ show_now_playing: true })); + render(SettingsHome); + + await vi.waitFor(() => { + const toggles = document.querySelectorAll( + 'input[type="checkbox"].toggle' + ) as NodeListOf; + expect(toggles.length).toBeGreaterThanOrEqual(2); + expect(toggles[1].checked).toBe(true); + }); + }); }); diff --git a/frontend/src/lib/components/settings/SettingsPreferences.svelte b/frontend/src/lib/components/settings/SettingsPreferences.svelte index 069af50..2e8bfa2 100644 --- a/frontend/src/lib/components/settings/SettingsPreferences.svelte +++ b/frontend/src/lib/components/settings/SettingsPreferences.svelte @@ -6,7 +6,9 @@ UserPreferences, ReleaseTypeOption, LidarrMetadataProfilePreferences, - MetadataProfile + MetadataProfile, + TrackButtonKey, + DownloadOptionsContext } from '$lib/types'; import { invalidateQueriesWithPersister } from '$lib/queries/QueryClient'; import { ArtistQueryKeyFactory } from '$lib/queries/artist/ArtistQueryKeyFactory'; @@ -14,7 +16,31 @@ let preferences: UserPreferences = $state({ primary_types: [], secondary_types: [], - release_statuses: [] + release_statuses: [], + // Defaults all-on — mirrors backend.TrackButtonVisibility defaults + // and pre-fork behavior. Replaced by the server response on load. + download_options: { + popular_songs: { + lidarr_request: true, + track_download: true, + preview: true, + yt_play: true, + jellyfin: true, + local_files: true, + navidrome: true, + plex: true + }, + album_page: { + lidarr_request: true, + track_download: true, + preview: true, + yt_play: true, + jellyfin: true, + local_files: true, + navidrome: true, + plex: true + } + } }); let saving = $state(false); let saveMessage = $state(''); @@ -62,6 +88,94 @@ { id: 'pseudo-release', title: 'Pseudo-Release', description: 'Placeholder or meta releases' } ]; + // Per-button metadata for the Download Options table. `contexts` lists + // where the button actually renders today — the toggle is hidden for + // contexts where the button isn't rendered (e.g., the Popular Songs + // row only shows lidarr_request + track_download). Keeping the schema + // carrying all 8 keys in both contexts means a future expansion (e.g. + // adding Plex playback to Popular Songs) needs no migration — just + // flip the `contexts` array here. + type TrackButtonOption = { + key: TrackButtonKey; + title: string; + description: string; + contexts: DownloadOptionsContext[]; + }; + + // "Download" buttons — actions that pull a track into the library. + // TrackDownloadButton already covers both YouTube and Spotify behind + // its own source picker, so it counts as one row regardless of source. + const downloadButtons: TrackButtonOption[] = [ + { + key: 'lidarr_request', + title: 'Request via Lidarr', + description: 'Adds the track to Lidarr and triggers a search through your configured indexers.', + contexts: ['popular_songs', 'album_page'] + }, + { + key: 'track_download', + title: 'Direct download (yt-dlp)', + description: + 'Grabs the track via the yt-dlp-worker. The button itself has an internal YouTube / Spotify source picker.', + contexts: ['popular_songs', 'album_page'] + } + ]; + + // "Playback" buttons — listen / queue / play, no library write. Each + // is still source-availability-gated (the Jellyfin button only renders + // when a Jellyfin server is configured AND the track is mapped to a + // file there); unchecking here force-hides on top of that gate. + const playbackButtons: TrackButtonOption[] = [ + { + key: 'preview', + title: 'YouTube preview', + description: 'Inline preview/scrub of the track on YouTube without leaving the page.', + contexts: ['album_page'] + }, + { + key: 'yt_play', + title: 'YouTube play', + description: 'Queue the track for playback via the YouTube player.', + contexts: ['album_page'] + }, + { + key: 'jellyfin', + title: 'Jellyfin', + description: 'Play the track from your Jellyfin server when available.', + contexts: ['album_page'] + }, + { + key: 'local_files', + title: 'Local files', + description: 'Play the track from the local-files library when available.', + contexts: ['album_page'] + }, + { + key: 'navidrome', + title: 'Navidrome', + description: 'Play the track from your Navidrome server when available.', + contexts: ['album_page'] + }, + { + key: 'plex', + title: 'Plex', + description: 'Play the track from your Plex server when available.', + contexts: ['album_page'] + } + ]; + + function toggleDownloadOption(context: DownloadOptionsContext, key: TrackButtonKey): void { + // Object-spread re-assignment for Svelte 5 deep-reactivity safety — + // matches the immutable update style used by toggleType above. + preferences.download_options = { + ...preferences.download_options, + [context]: { + ...preferences.download_options[context], + [key]: !preferences.download_options[context][key] + } + }; + } + function toggleType( category: 'primary_types' | 'secondary_types' | 'release_statuses', id: string @@ -308,6 +422,46 @@
{/snippet} +{#snippet trackButtonTable(buttons: TrackButtonOption[], context: DownloadOptionsContext)} + {@const rows = buttons.filter((b) => b.contexts.includes(context))} + {#if rows.length === 0} +

+ (none of these buttons render in this slot today) +

+ {:else} +
+ + + + + + + + + + {#each rows as btn (btn.key)} + {@const enabled = preferences.download_options[context][btn.key]} + + + + + + {/each} + +
+ Show + Button
+ toggleDownloadOption(context, btn.key)} + /> + {btn.title}
+
+ {/if} +{/snippet} +

Included Releases

@@ -387,25 +541,67 @@

Release Statuses

{@render typeTable(releaseStatuses, 'release_statuses')}
+
+ -
- {#if saveMessage} -
- {saveMessage} -
- {/if} - +
+
+

Download Options

+

+ Choose which download buttons appear next to each track. Direct download already + covers both YouTube and Spotify via the button's own source picker, so it counts once. + Playback / preview buttons live in the separate card below. +

+ +
+

Album Track List

+ {@render trackButtonTable(downloadButtons, 'album_page')} +
+ +
+

Popular Songs

+ {@render trackButtonTable(downloadButtons, 'popular_songs')}
+ +
+
+

Playback Buttons

+

+ Choose which playback buttons appear next to each track. Unchecking forces a button + hidden; checking lets the existing source-availability gate decide (e.g., the Jellyfin button + still only renders when a Jellyfin server is configured and the track is mapped there). +

+ +
+

Album Track List

+ {@render trackButtonTable(playbackButtons, 'album_page')} +
+ +
+

Popular Songs

+ {@render trackButtonTable(playbackButtons, 'popular_songs')} +
+
+
+ +
+ {#if saveMessage} +
+ {saveMessage} +
+ {/if} + +
diff --git a/frontend/src/lib/stores/homeSettings.svelte.ts b/frontend/src/lib/stores/homeSettings.svelte.ts new file mode 100644 index 0000000..f58d260 --- /dev/null +++ b/frontend/src/lib/stores/homeSettings.svelte.ts @@ -0,0 +1,54 @@ +import { get, writable } from 'svelte/store'; +import { api } from '$lib/api/client'; +import type { HomeSettings } from '$lib/types'; + +const API = '/api/v1/settings/home'; + +interface HomeSettingsState extends HomeSettings { + loaded: boolean; +} + +const defaults: HomeSettingsState = { + cache_ttl_trending: 3600, + cache_ttl_personal: 300, + show_whats_hot: true, + show_globally_trending: true, + show_now_playing: false, + loaded: false +}; + +function createHomeSettingsStore() { + const { subscribe, set, update } = writable(defaults); + let loadPromise: Promise | null = null; + + async function load(): Promise { + try { + const settings = await api.global.get(API); + update((state) => ({ ...state, ...settings, loaded: true })); + } catch { + update((state) => ({ ...state, loaded: true })); + } + } + + return { + subscribe, + load, + refresh: load, + ensureLoaded: async (): Promise => { + const current = get({ subscribe }); + if (current.loaded) return; + if (loadPromise) return loadPromise; + + loadPromise = load().finally(() => { + loadPromise = null; + }); + return loadPromise; + }, + get showNowPlaying(): boolean { + return get({ subscribe }).show_now_playing; + }, + reset: () => set(defaults) + }; +} + +export const homeSettingsStore = createHomeSettingsStore(); diff --git a/frontend/src/lib/stores/libraryRefresh.svelte.ts b/frontend/src/lib/stores/libraryRefresh.svelte.ts new file mode 100644 index 0000000..c1d39f4 --- /dev/null +++ b/frontend/src/lib/stores/libraryRefresh.svelte.ts @@ -0,0 +1,30 @@ +/** + * Monotonically-increasing counter that components can subscribe to in order + * to know "the local-library state may have changed; re-fetch anything that + * depends on it." + * + * Mechanism: TrackDownloadButton calls `bump()` on first-seen `status=done` + * for a download. Components that render library-derived state (notably + * TopSongsList's resolveMap) include `libraryRefresh.version` in their + * effect-dependency key, so the effect re-runs when the counter ticks. + * + * Cheap, ephemeral (in-memory, lost on reload), and decoupled — the producer + * (download flow) and consumers (any future component showing in-library state) + * don't need to know about each other. + */ + +function createLibraryRefreshStore() { + let version = $state(0); + + return { + get version(): number { + return version; + }, + + bump(): void { + version += 1; + } + }; +} + +export const libraryRefresh = createLibraryRefreshStore(); diff --git a/frontend/src/lib/stores/nowPlayingSessions.svelte.spec.ts b/frontend/src/lib/stores/nowPlayingSessions.svelte.spec.ts new file mode 100644 index 0000000..80108b1 --- /dev/null +++ b/frontend/src/lib/stores/nowPlayingSessions.svelte.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { writable } from 'svelte/store'; + +vi.mock('$env/dynamic/public', () => ({ + env: { PUBLIC_API_URL: '' } +})); + +// integrationStore needs to be a real Svelte store — nowPlayingSessions uses +// `get(integrationStore)` to decide which sources to poll. +const integrationState = writable({ + jellyfin: false, + navidrome: false, + plex: true, + loaded: true +}); +vi.mock('$lib/stores/integration', () => ({ + integrationStore: integrationState +})); + +let showNowPlaying = false; +vi.mock('$lib/stores/homeSettings.svelte', () => ({ + homeSettingsStore: { + get showNowPlaying() { + return showNowPlaying; + } + } +})); + +describe('nowPlayingStore privacy gate', () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + Object.defineProperty(document, 'hidden', { value: false, configurable: true }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.resetModules(); + }); + + it('skips the network when showNowPlaying is false', async () => { + showNowPlaying = false; + const fetchSpy = vi.fn(); + globalThis.fetch = fetchSpy; + + const { nowPlayingStore } = await import('./nowPlayingSessions.svelte'); + await nowPlayingStore.refresh(); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(nowPlayingStore.sessions.length).toBe(0); + }); + + it('hits the configured source when showNowPlaying is true', async () => { + showNowPlaying = true; + const fetchSpy = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ sessions: [] }), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + ); + globalThis.fetch = fetchSpy; + + const { nowPlayingStore } = await import('./nowPlayingSessions.svelte'); + await nowPlayingStore.refresh(); + + // Plex is the only integration configured in the mock above — exactly + // one fetch should land on the Plex sessions endpoint. + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(String(fetchSpy.mock.calls[0][0])).toContain('plex'); + }); +}); diff --git a/frontend/src/lib/stores/nowPlayingSessions.svelte.ts b/frontend/src/lib/stores/nowPlayingSessions.svelte.ts index acd2261..726e7b2 100644 --- a/frontend/src/lib/stores/nowPlayingSessions.svelte.ts +++ b/frontend/src/lib/stores/nowPlayingSessions.svelte.ts @@ -1,5 +1,6 @@ import { API } from '$lib/constants'; import { integrationStore } from '$lib/stores/integration'; +import { homeSettingsStore } from '$lib/stores/homeSettings.svelte'; import { get } from 'svelte/store'; import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import type { @@ -113,6 +114,17 @@ function createNowPlayingStore() { async function fetchAll() { if (typeof document !== 'undefined' && document.hidden) return; + // Privacy gate: when the home setting is off, drop any cached server + // sessions and skip the network fetch entirely. The merged store + // still emits the user's local MusicSeerr playback because that is + // built from playerStore, not from this feed. + if (!homeSettingsStore.showNowPlaying) { + if (sessions.length > 0) sessions = []; + lastGoodSessions.clear(); + interpBasis.clear(); + return; + } + const integrations = get(integrationStore); const fetches: Promise<{ source: SourceKey; diff --git a/frontend/src/lib/stores/preferences.ts b/frontend/src/lib/stores/preferences.ts index 29a94cf..ada600a 100644 --- a/frontend/src/lib/stores/preferences.ts +++ b/frontend/src/lib/stores/preferences.ts @@ -1,13 +1,32 @@ import { writable } from 'svelte/store'; -import type { UserPreferences } from '$lib/types'; +import type { UserPreferences, TrackButtonVisibility } from '$lib/types'; import { api } from '$lib/api/client'; const API_BASE = '/api/v1'; +// All-true visibility — matches backend.TrackButtonVisibility defaults. +// Used as the client-side default so first paint (before /preferences +// resolves) renders the full cluster, matching pre-fork behavior. The +// server's response replaces this on load. +const allVisible: TrackButtonVisibility = { + lidarr_request: true, + track_download: true, + preview: true, + yt_play: true, + jellyfin: true, + local_files: true, + navidrome: true, + plex: true +}; + const defaultPreferences: UserPreferences = { primary_types: ['album', 'ep', 'single'], secondary_types: ['studio'], - release_statuses: ['official'] + release_statuses: ['official'], + download_options: { + popular_songs: { ...allVisible }, + album_page: { ...allVisible } + } }; const { subscribe, set, update } = writable(defaultPreferences); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a3586c9..25fbe63 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -173,10 +173,34 @@ export type ArtistReleases = { source_total_count: number | null; }; +// Mirrors backend.TrackButtonVisibility — per-context force-off flags +// for the track-row action cluster. true = let the existing +// source-availability gate decide; false = always hide. +export type TrackButtonVisibility = { + lidarr_request: boolean; + track_download: boolean; + preview: boolean; + yt_play: boolean; + jellyfin: boolean; + local_files: boolean; + navidrome: boolean; + plex: boolean; +}; + +export type TrackButtonKey = keyof TrackButtonVisibility; + +export type DownloadOptions = { + popular_songs: TrackButtonVisibility; + album_page: TrackButtonVisibility; +}; + +export type DownloadOptionsContext = keyof DownloadOptions; + export type UserPreferences = { primary_types: string[]; secondary_types: string[]; release_statuses: string[]; + download_options: DownloadOptions; }; export type ReleaseTypeOption = { @@ -273,6 +297,7 @@ export type HomeSettings = { cache_ttl_personal: number; show_whats_hot: boolean; show_globally_trending: boolean; + show_now_playing: boolean; }; export type HomeArtist = { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 6a10175..b390419 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -7,6 +7,7 @@ import { errorModal } from '$lib/stores/errorModal'; import { libraryStore } from '$lib/stores/library'; import { integrationStore } from '$lib/stores/integration'; + import { preferencesStore } from '$lib/stores/preferences'; import { initCacheTTLs } from '$lib/stores/cacheTtl'; import { playerStore } from '$lib/stores/player.svelte'; import { launchYouTubePlayback } from '$lib/player/launchYouTubePlayback'; @@ -40,6 +41,7 @@ import { requestCountStore } from '$lib/stores/requestCountStore.svelte'; import { nowPlayingMerged } from '$lib/stores/nowPlayingMerged.svelte'; import { nowPlayingStore } from '$lib/stores/nowPlayingSessions.svelte'; + import { homeSettingsStore } from '$lib/stores/homeSettings.svelte'; import SidebarVisualiser from '$lib/components/SidebarVisualiser.svelte'; import { createNavigationProgressController } from '$lib/utils/navigationProgress'; import { fromStore } from 'svelte/store'; @@ -140,12 +142,24 @@ deferInit(() => { libraryStore.initialize(); void imageSettingsStore.load(); + // Pull saved user preferences (download_options, included + // release types, etc.) so components like AlbumTrackList / + // TrackRow see the actual saved values on first paint instead + // of the all-true client defaults. Without this, the prefs + // only loaded when the Settings page mounted, so any other + // page rendered with stale defaults. + void preferencesStore.load(); void restorePlayerSession(); void scrobbleManager.init(); requestCountStore.startPolling(); syncStatus.connect(); }); - integrationStore.ensureLoaded().then(() => { + // Load home settings before starting the now-playing poller. The + // poller's fetchAll() gate keys on homeSettingsStore.showNowPlaying; + // without this load, the first 3s tick can leak server sessions + // against the default (which itself defaults False, but if it were + // ever flipped True server-side we want the gate to reflect it). + Promise.all([integrationStore.ensureLoaded(), homeSettingsStore.ensureLoaded()]).then(() => { nowPlayingStore.start(); }); }); diff --git a/frontend/src/routes/album/[id]/AlbumTrackList.svelte b/frontend/src/routes/album/[id]/AlbumTrackList.svelte index feea930..2fbbf1b 100644 --- a/frontend/src/routes/album/[id]/AlbumTrackList.svelte +++ b/frontend/src/routes/album/[id]/AlbumTrackList.svelte @@ -10,8 +10,10 @@ NavidromeAlbumMatch, NavidromeTrackInfo, PlexAlbumMatch, - PlexTrackInfo + PlexTrackInfo, + TrackButtonVisibility } from '$lib/types'; + import { preferencesStore } from '$lib/stores/preferences'; import type { MenuItem } from '$lib/components/ContextMenu.svelte'; import type { RenderedTrackSection } from './albumTrackResolvers'; import { resolveSourceTrack } from './albumTrackResolvers'; @@ -23,6 +25,14 @@ import TrackPlayButton from '$lib/components/TrackPlayButton.svelte'; import TrackPreviewButton from '$lib/components/TrackPreviewButton.svelte'; import TrackSourceButton from '$lib/components/TrackSourceButton.svelte'; + import TrackDownloadButton from '$lib/components/TrackDownloadButton.svelte'; + import LidarrRequestButton from '$lib/components/LidarrRequestButton.svelte'; + import { + getLidarrRequestStatus, + projectButtonStatus, + buildStatusLookup, + type LidarrButtonStatus + } from '$lib/api/lidarrRequest'; import ContextMenu from '$lib/components/ContextMenu.svelte'; import JellyfinIcon from '$lib/components/JellyfinIcon.svelte'; import LocalFilesIcon from '$lib/components/LocalFilesIcon.svelte'; @@ -99,6 +109,80 @@ onQuotaUpdate, getTrackContextMenuItems }: Props = $props(); + + // Lidarr per-track status. Re-fetched + polled on every album change. + // + // IMPORTANT: must use $effect (not onMount) for setup so the lookup + // rebuilds when SvelteKit navigates between albums — SvelteKit reuses + // this component across `/album/A` → `/album/B` transitions, so + // onMount only fires once and a stale Album A lookup would leak into + // Album B's rendering. The position+disc fallback would then collide + // (positions 1,2,3… exist on every album) and every track on Album B + // would falsely render as "requested in Lidarr". + let statusLookup = $state | null>(null); + + $effect(() => { + // Reactive dep: re-run whenever the album mbid changes. + const albumMbid = album?.musicbrainz_id; + if (!albumMbid) { + statusLookup = null; + return; + } + + // Reset the lookup BEFORE the fetch lands so buttons default to + // idle during the transition (better than briefly showing the + // previous album's state). + statusLookup = null; + + let cancelled = false; + async function refresh() { + try { + const res = await getLidarrRequestStatus(albumMbid); + if (cancelled) return; + statusLookup = buildStatusLookup(albumMbid, res); + } catch { + // Lidarr unreachable or album not in library — leave null. + // Buttons fall back to `none` (idle) and remain clickable. + } + } + + refresh(); + const handle = setInterval(refresh, 30000); + + return () => { + cancelled = true; + clearInterval(handle); + }; + }); + + function statusFor( + recordingId: string | null | undefined, + position: number, + disc: number + ): LidarrButtonStatus { + if (!statusLookup) return 'none'; + const albumMbid = album?.musicbrainz_id; + if (!albumMbid) return 'none'; + return projectButtonStatus(statusLookup.lookup(albumMbid, recordingId, position, disc)); + } + + // User's per-button visibility prefs for the album-page slot. Each + // existing per-button render-gate (showJellyfinBtn, youtubeEnabled, + // etc.) is AND'd with the matching flag here — unchecked = force-off, + // checked = the existing source-availability gate decides. + let buttonVisibility = $state({ + lidarr_request: true, + track_download: true, + preview: true, + yt_play: true, + jellyfin: true, + local_files: true, + navidrome: true, + plex: true + }); + preferencesStore.subscribe((prefs) => { + buttonVisibility = prefs.download_options.album_page; + });
@@ -151,22 +235,32 @@ (playerStore.currentQueueItem?.discNumber ?? 1) === trackDiscNumber && playerStore.currentQueueItem?.trackNumber === track.position && playerStore.isPlaying} - {@const showJellyfinBtn = jellyfinEnabled && jellyfinMatch?.found} - {@const showLocalBtn = localfilesEnabled && localMatch?.found} - {@const showNavidromeBtn = navidromeEnabled && navidromeMatch?.found} - {@const showPlexBtn = plexEnabled && plexMatch?.found} + {@const showJellyfinBtn = buttonVisibility.jellyfin && jellyfinEnabled && jellyfinMatch?.found} + {@const showLocalBtn = buttonVisibility.local_files && localfilesEnabled && localMatch?.found} + {@const showNavidromeBtn = buttonVisibility.navidrome && navidromeEnabled && navidromeMatch?.found} + {@const showPlexBtn = buttonVisibility.plex && plexEnabled && plexMatch?.found} {@const hasAnySource = tl !== null || jellyfinTrack !== null || localTrack !== null || navidromeTrack !== null || plexTrack !== null} - {@const showPreview = youtubeApiConfigured && !hasAnySource} + {@const showPreview = buttonVisibility.preview && youtubeApiConfigured && !hasAnySource} + {@const showYtPlay = buttonVisibility.yt_play && youtubeEnabled} + {@const showLidarrRequest = buttonVisibility.lidarr_request && !!track.recording_id} + {@const showTrackDownload = buttonVisibility.track_download}
  • -
    + +
    +
    - {#if youtubeEnabled || showPreview || showJellyfinBtn || showLocalBtn || showNavidromeBtn || showPlexBtn} -
    +
    {#if showPreview} {/if} - {#if youtubeEnabled} + {#if showYtPlay} {/if} + {#if showLidarrRequest} + + {/if} + + {#if showTrackDownload} + + {/if} +
    - {/if}
  • {/each}