From 23c9125ad85c4b35b6b12f57e47b26baa54cf882 Mon Sep 17 00:00:00 2001 From: Shaun Reed Date: Fri, 29 May 2026 23:43:05 +0000 Subject: [PATCH] =?UTF-8?q?Personal=20fork=20of=20habirabbu/musicseerr=20?= =?UTF-8?q?=E2=80=94=20multi-instance=20+=20inline=20downloads=20+=20lidar?= =?UTF-8?q?r-request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashes 26 incremental fork commits (Apr–May 2026) onto upstream main as a single diff for cleaner cross-fork comparison. Original history preserved on the pre-squash-backup tag locally. Feature additions ───────────────── • Inline single-track download via yt-dlp-worker proxy New routes: POST /api/v1/track-download/search (source: youtube | spotify), POST /api/v1/track-download, GET /api/v1/track-download/{id}. Frontend TrackDownloadButton in album track list AND popular-songs row, with a per-button source picker. Per-user rate limits live in the worker's SQLite store. On completion the backend fires Lidarr RefreshArtist + Plex library refresh + cache invalidation, and the popular-songs list auto-refreshes. • Per-instance library pinning via MUSICSEERR_LIBRARY env Backend stamps the library label server-side (music / music-personal / music-shared); clients cannot override. Drives an instance-segregated deployment of three musicseerr containers sharing one source tree. • Lidarr-request flow (single-track requests via Lidarr indexers) New routes: POST /api/v1/lidarr-request, GET /api/v1/lidarr-request/status. Per-album asyncio.Lock keyed on album_mbid so rapid-clicks on the same album serialize correctly. Cross-release track matcher with foreignTrackId → foreignRecordingId → position+disc → exact-title → substring fallback chain, evaluated per release (recording UUIDs frequently differ between album, single, and deluxe edition releases of the same song). Flips artist.monitored = True on request so Lidarr's WantedAlbums query reaches the track. Full Lidarr-chain gate (artist AND album AND track) for the status endpoint to avoid false-positive REQUESTED display. Persistent UI state so button icons survive refresh and cross-album navigation. • Privacy: show_now_playing toggle in Settings → Home Default off. Plex /status/sessions returns active audio sessions across the whole server with no library-section filter, so a shared instance leaks every household member's listening activity. The merged store still emits the user's local MusicSeerr playback bar; only server-derived sessions (Plex / Jellyfin / Navidrome) are gated. • Per-button visibility prefs for the track-row action cluster Settings → Preferences → Download Options / Playback Buttons. Per-context (popular_songs / album_page) force-off flags layered on top of the existing source-availability gate. • UX: wrap action cluster on mobile, hide LidarrRequestButton in tight layouts, cross-album status-leak fix in AlbumTrackList ($effect keyed on album.musicbrainz_id to rebuild lookup; map keyed by "{albumMbid}:{position}:{disc}"). Test coverage ───────────── Backend pytest: full suite green (2031/2031 as of squash). New: schema-default tests for HomeSettings, lidarr_request_service cross-release matcher regression test, singleton-registry expected-count bump to 59. Frontend vitest: SettingsHome.svelte.spec covers new toggle, nowPlayingSessions .svelte.spec covers the privacy gate (no fetch when off; fetches when on). --- backend/api/v1/routes/lidarr_request.py | 52 ++ backend/api/v1/routes/stream.py | 42 ++ backend/api/v1/routes/track_download.py | 60 +++ backend/api/v1/schemas/lidarr_request.py | 96 ++++ backend/api/v1/schemas/settings.py | 57 ++- backend/api/v1/schemas/track_download.py | 78 +++ backend/cache/.gitignore-check | 0 backend/core/config.py | 46 ++ backend/core/dependencies/__init__.py | 3 + .../core/dependencies/service_providers.py | 24 + backend/core/dependencies/type_aliases.py | 3 + backend/main.py | 7 + backend/repositories/lidarr/album.py | 153 +++++- backend/repositories/lidarr/artist.py | 31 ++ backend/repositories/lidarr/repository.py | 6 +- backend/services/library_service.py | 7 +- backend/services/lidarr_request_service.py | 483 ++++++++++++++++++ backend/services/track_download_service.py | 323 ++++++++++++ .../schemas/test_home_settings_schema.py | 16 + .../services/test_lidarr_request_service.py | 203 ++++++++ backend/tests/test_dependencies_package.py | 6 +- backend/tests/test_local_files_fallback.py | 2 +- frontend/src/lib/api/lidarrRequest.ts | 104 ++++ frontend/src/lib/api/trackDownload.ts | 91 ++++ .../lib/components/LidarrRequestButton.svelte | 167 ++++++ .../src/lib/components/TopSongsList.svelte | 62 ++- .../lib/components/TrackDownloadButton.svelte | 390 ++++++++++++++ frontend/src/lib/components/TrackRow.svelte | 101 +++- ...hen-provided-and-a-letter-is-clicked-1.png | Bin 13136 -> 0 bytes ...lte-disables-letters-without-content-1.png | Bin 12511 -> 0 bytes ...ders-all-27-letter-buttons--A-Z------1.png | Bin 11921 -> 0 bytes ...l-appends--medium-suffix-for-lg-size-1.png | Bin 14466 -> 0 bytes ...referrerpolicy-when-remoteUrl-is-set-1.png | Bin 14469 -> 0 bytes ...RL-for-artist-when-remoteUrl-is-null-1.png | Bin 4990 -> 0 bytes ...RL-when-remoteUrl-is-null-for-artist-1.png | Bin 4990 -> 0 bytes ...eUrl-uses-original-URL-for-full-size-1.png | Bin 14466 -> 0 bytes .../components/settings/SettingsHome.svelte | 28 + .../settings/SettingsHome.svelte.spec.ts | 39 +- .../settings/SettingsPreferences.svelte | 236 ++++++++- .../src/lib/stores/homeSettings.svelte.ts | 54 ++ .../src/lib/stores/libraryRefresh.svelte.ts | 30 ++ .../stores/nowPlayingSessions.svelte.spec.ts | 72 +++ .../lib/stores/nowPlayingSessions.svelte.ts | 12 + frontend/src/lib/stores/preferences.ts | 23 +- frontend/src/lib/types.ts | 25 + frontend/src/routes/+layout.svelte | 16 +- .../routes/album/[id]/AlbumTrackList.svelte | 139 ++++- 47 files changed, 3208 insertions(+), 79 deletions(-) create mode 100644 backend/api/v1/routes/lidarr_request.py create mode 100644 backend/api/v1/routes/track_download.py create mode 100644 backend/api/v1/schemas/lidarr_request.py create mode 100644 backend/api/v1/schemas/track_download.py delete mode 100644 backend/cache/.gitignore-check create mode 100644 backend/services/lidarr_request_service.py create mode 100644 backend/services/track_download_service.py create mode 100644 backend/tests/schemas/test_home_settings_schema.py create mode 100644 backend/tests/services/test_lidarr_request_service.py create mode 100644 frontend/src/lib/api/lidarrRequest.ts create mode 100644 frontend/src/lib/api/trackDownload.ts create mode 100644 frontend/src/lib/components/LidarrRequestButton.svelte create mode 100644 frontend/src/lib/components/TrackDownloadButton.svelte delete mode 100644 frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-calls-onBeforeJump-when-provided-and-a-letter-is-clicked-1.png delete mode 100644 frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-disables-letters-without-content-1.png delete mode 100644 frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-renders-all-27-letter-buttons--A-Z------1.png delete mode 100644 frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-appends--medium-suffix-for-lg-size-1.png delete mode 100644 frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-CDN-URL-with-referrerpolicy-when-remoteUrl-is-set-1.png delete mode 100644 frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-proxy-URL-for-artist-when-remoteUrl-is-null-1.png delete mode 100644 frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-proxy-URL-when-remoteUrl-is-null-for-artist-1.png delete mode 100644 frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-uses-original-URL-for-full-size-1.png create mode 100644 frontend/src/lib/stores/homeSettings.svelte.ts create mode 100644 frontend/src/lib/stores/libraryRefresh.svelte.ts create mode 100644 frontend/src/lib/stores/nowPlayingSessions.svelte.spec.ts 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 e4e97aa2ef5ead890869d2ba9b4c1ffb8de13b5d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13136 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 Bz>fd` 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 4f9042363a07cd3ed43b6f0b8370f7b0a0b58da9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12511 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 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 f72190d4fa32062d6afea00450c0193962e9f14c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11921 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 Q@Pt5KR^@%^TeHCb2I(jUS^xk5 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 f0c706edccbac8d4ed5b1cd0dde66b7ac054b3cd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14466 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 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 855e9ef37224be70033b5ff5a08db62dafc450d5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14469 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 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 37d4fad194be72db9507ba24e4378afca7f59797..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4990 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 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}