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 @@