Personal fork of habirabbu/musicseerr — multi-instance + inline downloads + lidarr-request
Backend CI / Lint (push) Waiting to run
Backend CI / Tests (push) Waiting to run

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).
This commit is contained in:
2026-05-29 23:43:05 +00:00
parent e70a76f489
commit 23c9125ad8
47 changed files with 3208 additions and 79 deletions
+52
View File
@@ -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)
+42
View File
@@ -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")
+60
View File
@@ -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)
+96
View File
@@ -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]
+50 -7
View File
@@ -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/<id> returns 404 because the remap
# produces /music/data/<artist>/... which doesn't exist.
lidarr_root_path: str = "/data"
class LocalFilesVerifyResponse(AppStruct):
+78
View File
@@ -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
View File
+46
View File
@@ -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/<id>/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
+3
View File
@@ -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
@@ -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())
@@ -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)]
+7
View File
@@ -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)
+137 -16
View File
@@ -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(
+31
View File
@@ -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"}
+4 -2
View File
@@ -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
)
+6 -1
View File
@@ -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(
+483
View File
@@ -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
+323
View File
@@ -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/<id>/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()
@@ -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
@@ -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]
+5 -1
View File
@@ -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:
+1 -1
View File
@@ -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(
+104
View File
@@ -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<string, LidarrRequestTrackStatus>();
const byPositionDisc = new Map<string, LidarrRequestTrackStatus>();
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<LidarrRequestAccepted> {
return api.global.post<LidarrRequestAccepted>(ROOT, payload, { signal });
}
export async function getLidarrRequestStatus(
albumMbid: string,
signal?: AbortSignal
): Promise<LidarrRequestStatusResponse> {
return api.global.get<LidarrRequestStatusResponse>(
`${ROOT}/status?album_mbid=${encodeURIComponent(albumMbid)}`,
{ signal }
);
}
+91
View File
@@ -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<TrackDownloadStatus> = 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<TrackDownloadSearchResponse> {
return api.global.post<TrackDownloadSearchResponse>(
`${ROOT}/search`,
{ query, limit, source },
{ signal }
);
}
export async function requestTrackDownload(
payload: TrackDownloadRequestPayload,
signal?: AbortSignal
): Promise<TrackDownloadAccepted> {
return api.global.post<TrackDownloadAccepted>(ROOT, payload, { signal });
}
export async function getTrackDownloadJob(
jobId: string,
signal?: AbortSignal
): Promise<TrackDownloadJobStatus> {
return api.global.get<TrackDownloadJobStatus>(`${ROOT}/${jobId}`, { signal });
}
@@ -0,0 +1,167 @@
<script lang="ts">
import { untrack } from 'svelte';
import { Library, Check, AlertTriangle, Loader2, Hourglass } from 'lucide-svelte';
import { ApiError } from '$lib/api/client';
import { requestTrackViaLidarr, type LidarrButtonStatus } from '$lib/api/lidarrRequest';
import { colors } from '$lib/colors';
// LidarrButtonStatus comes from the API client module. Hydrating via
// `initialStatus` lets the page render `requested` / `downloaded` even
// after a refresh — without it, the button only knows transient session
// state and forgets every prior click.
interface Props {
albumMbid: string;
trackMbid: string;
trackTitle: string;
artistMbid?: string | null;
trackPosition?: number | null;
discNumber?: number | null;
size?: 'sm' | 'md';
initialStatus?: LidarrButtonStatus;
}
let {
albumMbid,
trackMbid,
trackTitle,
artistMbid = null,
trackPosition = null,
discNumber = null,
size = 'sm',
initialStatus = 'none'
}: Props = $props();
type ButtonState = 'idle' | 'submitting' | 'failed' | 'requested' | 'downloaded';
// `state` is what we actually render. Hydrate from initialStatus on
// mount, then a successful click flips us to `requested` immediately
// (optimistic — Lidarr just confirmed it accepted the search).
let state = $state<ButtonState>(initialStatusToState(initialStatus));
let errorMsg = $state<string | null>(null);
let successNote = $state<string | null>(null);
// React to upstream initialStatus changes (e.g., page refresh re-fetched
// the parent's status map and a track that was idle now shows as
// downloaded because a background grab finished).
//
// IMPORTANT: read `state` via `untrack` so this effect only depends on
// `initialStatus`. Without it, the optimistic flip in handleClick
// (state = 'requested') re-fires this effect, and because the parent
// hasn't re-polled yet `initialStatus` is still 'none', so state gets
// reset to 'idle' and the user sees the button revert. Hit 2026-05-29
// on Led Zeppelin — button briefly showed Hourglass then snapped back
// to Library until a page refresh.
//
// Additionally, only ADOPT the parent's view if it's at least as
// strong as our local state: idle (0) < requested (1) < downloaded (2).
// This prevents the parent's late-arriving 'none' from downgrading
// our just-flipped 'requested'. Upgrades (requested → downloaded)
// still flow through normally.
const STATE_RANK: Record<ButtonState, number> = {
idle: 0,
submitting: 0,
failed: 0,
requested: 1,
downloaded: 2
};
$effect(() => {
const incoming = initialStatusToState(initialStatus);
untrack(() => {
if (state === 'submitting' || state === 'failed') return;
if (STATE_RANK[incoming] >= STATE_RANK[state]) {
state = incoming;
}
});
});
function initialStatusToState(s: LidarrButtonStatus): ButtonState {
switch (s) {
case 'downloaded':
return 'downloaded';
case 'requested':
return 'requested';
default:
return 'idle';
}
}
const isReady = $derived(!!albumMbid && !!trackMbid);
async function handleClick(e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
if (!isReady) return;
// Don't fire if we already know Lidarr has it (monitored or on disk).
// Click stays a no-op visually — the persistent icon is enough signal.
if (state === 'requested' || state === 'downloaded' || state === 'submitting') return;
state = 'submitting';
errorMsg = null;
successNote = null;
try {
const res = await requestTrackViaLidarr({
album_mbid: albumMbid,
track_mbid: trackMbid,
artist_mbid: artistMbid,
track_position: trackPosition,
disc_number: discNumber,
// Title is the last-ditch fallback for the backend matcher.
// Lidarr's foreignRecordingId doesn't always equal MB's
// recording_id, and Popular Songs lists often lack track
// position/disc — title gets us through both cases.
track_title: trackTitle
});
successNote = res.note;
// Optimistic flip: Lidarr accepted, treat as requested. If a
// later parent refetch flips us to `downloaded`, the $effect
// above carries us there.
state = 'requested';
} catch (e: unknown) {
state = 'failed';
errorMsg = e instanceof ApiError ? e.message : 'Lidarr request failed';
setTimeout(() => {
if (state === 'failed') state = 'idle';
}, 8000);
}
}
const tooltip = $derived.by(() => {
if (!isReady) return 'Missing MusicBrainz IDs — cannot request via Lidarr';
if (state === 'submitting') return `Requesting "${trackTitle}" via Lidarr…`;
if (state === 'downloaded') return `Already in your library — Lidarr downloaded "${trackTitle}"`;
if (state === 'requested')
return successNote
? `Requested via Lidarr (${successNote}). Will appear in library when found.`
: `Already requested in Lidarr — waiting for the next grab to land`;
if (state === 'failed') return errorMsg ?? 'Lidarr request failed';
return `Request "${trackTitle}" via Lidarr (uses your configured indexers)`;
});
// Click is only actionable in idle state. When the request is already
// in-flight in Lidarr (or done), the icon is informational.
const isActionable = $derived(state === 'idle' || state === 'failed');
</script>
<button
type="button"
class="btn btn-circle btn-sm btn-ghost {size === 'sm'
? 'min-h-[36px] min-w-[36px]'
: 'min-h-[44px] min-w-[44px]'} border border-base-content/10 shrink-0 active:scale-[0.95]"
class:cursor-default={!isActionable && state !== 'submitting'}
title={tooltip}
aria-label="Request {trackTitle} via Lidarr"
disabled={!isReady}
onclick={handleClick}
>
{#if state === 'submitting'}
<Loader2 class="h-4 w-4 animate-spin" color={colors.accent} />
{:else if state === 'downloaded'}
<Check class="h-4 w-4" color={colors.accent} />
{:else if state === 'requested'}
<Hourglass class="h-4 w-4" color={colors.accent} />
{:else if state === 'failed'}
<AlertTriangle class="h-4 w-4 text-error" />
{:else}
<Library class="h-4 w-4 opacity-70" />
{/if}
</button>
@@ -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<string, boolean>();
let resolveMap = new SvelteMap<string, ResolvedTrack>();
// 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<string, LidarrButtonStatus>();
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 ~1020 albums in parallel; cheap
// enough not to need a multi-album batch endpoint yet.
const uniqueAlbumMbids = new Set<string>();
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}
@@ -0,0 +1,390 @@
<script lang="ts">
import { CloudDownload, Music, X, Check, AlertTriangle, Loader2 } from 'lucide-svelte';
import { onDestroy } from 'svelte';
import { ApiError } from '$lib/api/client';
import {
searchTrackCandidates,
requestTrackDownload,
getTrackDownloadJob,
TRACK_DOWNLOAD_TERMINAL_STATES,
type TrackDownloadCandidate,
type TrackDownloadJobStatus,
type TrackDownloadStatus,
type TrackDownloadSource
} from '$lib/api/trackDownload';
import { colors } from '$lib/colors';
import { formatDurationSec } from '$lib/utils/formatting';
import { libraryRefresh } from '$lib/stores/libraryRefresh.svelte';
interface Props {
artist: string;
album: string;
trackTitle: string;
artistMbid?: string | null;
trackPosition?: number | null;
discNumber?: number | null;
size?: 'sm' | 'md';
}
const POLL_INTERVAL_MS = 2000;
const AUTO_CLOSE_AFTER_DONE_MS = 3000;
let {
artist,
album,
trackTitle,
artistMbid = null,
trackPosition = null,
discNumber = null,
size = 'sm'
}: Props = $props();
let dialogEl: HTMLDialogElement | undefined = $state();
let candidates = $state<TrackDownloadCandidate[]>([]);
let searching = $state(false);
let searchError = $state<string | null>(null);
let source = $state<TrackDownloadSource>('spotify');
let activeJobId = $state<string | null>(null);
let jobStatus = $state<TrackDownloadJobStatus | null>(null);
let jobError = $state<string | null>(null);
let submitting = $state(false);
let pollTimer: ReturnType<typeof setTimeout> | null = null;
let autoCloseTimer: ReturnType<typeof setTimeout> | null = null;
const isJobActive = $derived(
activeJobId !== null && (jobStatus === null || !TRACK_DOWNLOAD_TERMINAL_STATES.has(jobStatus.status))
);
const isJobDone = $derived(jobStatus?.status === 'done');
const isJobFailed = $derived(jobStatus?.status === 'failed');
function clearTimers() {
if (pollTimer !== null) {
clearTimeout(pollTimer);
pollTimer = null;
}
if (autoCloseTimer !== null) {
clearTimeout(autoCloseTimer);
autoCloseTimer = null;
}
}
function resetState() {
clearTimers();
candidates = [];
searching = false;
searchError = null;
activeJobId = null;
jobStatus = null;
jobError = null;
submitting = false;
}
async function loadCandidates() {
searching = true;
searchError = null;
candidates = [];
try {
const query = `${artist} ${trackTitle}`.trim();
const res = await searchTrackCandidates(query, 5, source);
candidates = res.candidates;
if (candidates.length === 0) {
const label = source === 'spotify' ? 'Spotify' : 'YouTube';
searchError = `No ${label} matches for "${query}"`;
}
} catch (e) {
searchError = e instanceof ApiError ? e.message : 'Search failed';
} finally {
searching = false;
}
}
function switchSource(next: TrackDownloadSource) {
if (next === source || searching || submitting) return;
source = next;
loadCandidates();
}
async function pollJob() {
if (activeJobId === null) return;
try {
const status = await getTrackDownloadJob(activeJobId);
const wasTerminal =
jobStatus !== null && TRACK_DOWNLOAD_TERMINAL_STATES.has(jobStatus.status);
jobStatus = status;
if (TRACK_DOWNLOAD_TERMINAL_STATES.has(status.status)) {
if (status.status === 'done' && !wasTerminal) {
// First-seen transition to done — tell any library-derived UI
// (TopSongsList resolveMap etc.) to refetch. Backend caches were
// already busted by the download-complete fan-out; this just
// kicks the frontend out of its own per-component cache.
libraryRefresh.bump();
autoCloseTimer = setTimeout(() => {
closeDialog();
}, AUTO_CLOSE_AFTER_DONE_MS);
}
return;
}
pollTimer = setTimeout(pollJob, POLL_INTERVAL_MS);
} catch (e) {
jobError = e instanceof ApiError ? e.message : 'Failed to poll status';
}
}
async function pickCandidate(c: TrackDownloadCandidate) {
if (submitting || isJobActive) return;
submitting = true;
jobError = null;
try {
const res = await requestTrackDownload({
video_id: c.video_id,
source: c.source,
target_duration_seconds: c.duration_seconds,
artist,
album,
track_title: trackTitle,
artist_mbid: artistMbid,
track_position: trackPosition,
disc_number: discNumber
});
activeJobId = res.job_id;
jobStatus = null;
pollJob();
} catch (e) {
jobError = e instanceof ApiError ? e.message : 'Failed to start download';
} finally {
submitting = false;
}
}
export function openDialog() {
if (!dialogEl) return;
// Preserve in-flight job; only reset if previous run completed.
if (!isJobActive && (jobStatus === null || TRACK_DOWNLOAD_TERMINAL_STATES.has(jobStatus.status))) {
resetState();
loadCandidates();
}
dialogEl.showModal();
}
function closeDialog() {
if (!dialogEl) return;
dialogEl.close();
// If job finished (done or failed), reset on close so next open is fresh.
if (jobStatus !== null && TRACK_DOWNLOAD_TERMINAL_STATES.has(jobStatus.status)) {
resetState();
}
}
function handleButtonClick(e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
openDialog();
}
function statusLabel(s: TrackDownloadStatus): string {
switch (s) {
case 'queued':
return 'Queued';
case 'searching':
return 'Searching';
case 'downloading':
return 'Downloading';
case 'tagging':
return 'Tagging';
case 'importing':
return 'Adding to library';
case 'done':
return 'Done';
case 'failed':
return 'Failed';
default:
return s;
}
}
onDestroy(clearTimers);
</script>
<button
type="button"
class="btn btn-circle btn-sm btn-ghost {size === 'sm'
? 'min-h-[36px] min-w-[36px]'
: 'min-h-[44px] min-w-[44px]'} border border-base-content/10 shrink-0 active:scale-[0.95]"
title={isJobActive
? `Track download: ${jobStatus ? statusLabel(jobStatus.status) : 'queued'}`
: isJobDone
? 'Downloaded'
: isJobFailed
? 'Last download failed — click to retry'
: `Download "${trackTitle}"`}
aria-label="Download {trackTitle}"
onclick={handleButtonClick}
>
{#if isJobActive}
<Loader2 class="h-4 w-4 animate-spin" color={colors.accent} />
{:else if isJobDone}
<Check class="h-4 w-4" color={colors.accent} />
{:else if isJobFailed}
<AlertTriangle class="h-4 w-4 text-error" />
{:else}
<CloudDownload class="h-4 w-4 opacity-70" />
{/if}
</button>
<dialog bind:this={dialogEl} class="modal">
<div class="modal-box max-w-2xl">
<form method="dialog">
<button
type="submit"
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
aria-label="Close"
onclick={closeDialog}
>
<X class="h-4 w-4" />
</button>
</form>
<div class="flex items-start gap-3">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg"
style="background-color: {colors.accent}20;"
>
<Music class="h-6 w-6" color={colors.accent} />
</div>
<div class="min-w-0 flex-1">
<div class="text-xs uppercase tracking-wide opacity-60">Download single track</div>
<div class="truncate text-lg font-semibold">{trackTitle}</div>
<div class="truncate text-sm opacity-70">{artist} {album}</div>
</div>
</div>
<div class="mt-3 flex justify-end">
<div class="join" role="tablist" aria-label="Search source">
<button
type="button"
role="tab"
aria-selected={source === 'spotify'}
class="btn btn-xs join-item {source === 'spotify' ? 'btn-active' : ''}"
disabled={searching || submitting || isJobActive}
onclick={() => switchSource('spotify')}
>
Spotify
</button>
<button
type="button"
role="tab"
aria-selected={source === 'youtube'}
class="btn btn-xs join-item {source === 'youtube' ? 'btn-active' : ''}"
disabled={searching || submitting || isJobActive}
onclick={() => switchSource('youtube')}
>
YouTube
</button>
</div>
</div>
<div class="divider my-3"></div>
{#if activeJobId !== null}
<!-- Job in progress / terminal -->
<div class="space-y-3 py-2">
{#if isJobDone}
<div class="flex items-center gap-3 text-success">
<Check class="h-6 w-6" />
<div>
<div class="font-semibold">Download complete</div>
<div class="text-xs opacity-70">
{jobStatus?.file_path ?? 'File saved'} Plex library refresh triggered.
</div>
</div>
</div>
{:else if isJobFailed}
<div class="flex items-start gap-3 text-error">
<AlertTriangle class="h-6 w-6 shrink-0" />
<div class="min-w-0">
<div class="font-semibold">Download failed</div>
<div class="break-words text-xs opacity-80">
{jobStatus?.error ?? jobError ?? 'Unknown error'}
</div>
</div>
</div>
{:else}
<div class="flex items-center gap-3">
<Loader2 class="h-5 w-5 animate-spin" color={colors.accent} />
<div class="text-sm">
<span class="font-medium">{statusLabel(jobStatus?.status ?? 'queued')}</span>
<span class="opacity-60"> yt-dlp working on it...</span>
</div>
</div>
<div class="text-xs opacity-50">
This usually takes 2060 seconds. You can close this dialog and the job will keep running.
</div>
{/if}
{#if jobError && !isJobFailed}
<div class="text-xs text-error">{jobError}</div>
{/if}
</div>
{:else if searching}
<div class="flex items-center gap-3 py-6">
<span class="loading loading-spinner loading-md" style="color: {colors.accent};"></span>
<span class="text-sm opacity-70">
Searching {source === 'spotify' ? 'Spotify' : 'YouTube'} for matches...
</span>
</div>
{:else if searchError}
<div class="alert alert-warning text-sm">
<AlertTriangle class="h-4 w-4" />
{searchError}
</div>
<div class="mt-3 flex justify-end">
<button type="button" class="btn btn-sm" onclick={loadCandidates}>Retry search</button>
</div>
{:else if candidates.length > 0}
<div class="text-xs opacity-60">
{#if source === 'spotify'}
Pick a Spotify match the worker will find the matching YouTube audio and tag it with
Spotify metadata.
{:else}
Pick the best YouTube match yt-dlp will download it to your music library.
{/if}
</div>
<ul class="mt-2 space-y-1">
{#each candidates as c (c.video_id)}
<li>
<button
type="button"
class="btn btn-block h-auto justify-start gap-3 px-3 py-2 normal-case"
disabled={submitting}
onclick={() => pickCandidate(c)}
>
{#if c.thumbnail_url}
<img
src={c.thumbnail_url}
alt=""
class="h-12 w-20 shrink-0 rounded object-cover"
loading="lazy"
/>
{:else}
<div class="h-12 w-20 shrink-0 rounded bg-base-300"></div>
{/if}
<div class="min-w-0 flex-1 text-left">
<div class="truncate font-medium">{c.title}</div>
<div class="truncate text-xs opacity-60">
{#if c.source === 'spotify'}
{c.artist ?? 'Unknown artist'}{#if c.album} · {c.album}{/if}
{:else}
{c.channel ?? 'Unknown channel'}
{/if}
{#if c.duration_seconds !== null} · {formatDurationSec(c.duration_seconds)}{/if}
</div>
</div>
</button>
</li>
{/each}
</ul>
{/if}
</div>
<form method="dialog" class="modal-backdrop">
<button type="submit" aria-label="close" onclick={closeDialog}>close</button>
</form>
</dialog>
+88 -13
View File
@@ -1,10 +1,14 @@
<script lang="ts">
import { albumHref } from '$lib/utils/entityRoutes';
import { Play, Disc3 } from 'lucide-svelte';
import type { TopSong, ResolvedTrack } from '$lib/types';
import type { TopSong, ResolvedTrack, TrackButtonVisibility } from '$lib/types';
import AlbumImage from './AlbumImage.svelte';
import LastFmPlaceholder from './LastFmPlaceholder.svelte';
import TrackPreviewButton from './TrackPreviewButton.svelte';
import TrackDownloadButton from './TrackDownloadButton.svelte';
import LidarrRequestButton from './LidarrRequestButton.svelte';
import { preferencesStore } from '$lib/stores/preferences';
import type { LidarrButtonStatus } from '$lib/api/lidarrRequest';
interface Props {
song: TopSong;
@@ -14,6 +18,7 @@
ytConfigured?: boolean;
initialCached?: boolean | null;
resolvedTrack?: ResolvedTrack | null;
lidarrStatus?: LidarrButtonStatus;
onPlay?: () => void;
}
@@ -25,6 +30,7 @@
ytConfigured = false,
initialCached = null,
resolvedTrack = null,
lidarrStatus = 'none',
onPlay
}: Props = $props();
@@ -32,6 +38,28 @@
let isLastfmNoAlbum = $derived(!hasAlbum && source === 'lastfm');
let canPlay = $derived(!!resolvedTrack?.source);
let previewEnabled = $derived(showPreview && ytConfigured && !canPlay);
// Worker requires a non-empty album for the file path; bucket release-less
// tracks under "Singles" so the download still lands somewhere sensible.
let downloadAlbum = $derived(song.release_name || 'Singles');
// Subscribe to the user's per-context button-visibility preferences.
// `popular_songs` is the relevant slot for TrackRow (used by the
// Popular Songs panel on artist pages). Defaults all-true so first
// paint matches pre-fork behavior; the server response replaces this
// on load.
let buttonVisibility = $state<TrackButtonVisibility>({
lidarr_request: true,
track_download: true,
preview: true,
yt_play: true,
jellyfin: true,
local_files: true,
navidrome: true,
plex: true
});
preferencesStore.subscribe((prefs) => {
buttonVisibility = prefs.download_options.popular_songs;
});
</script>
{#if hasAlbum}
@@ -39,13 +67,11 @@
{#if canPlay}
<button
onclick={onPlay}
class="w-6 shrink-0 flex items-center justify-center cursor-pointer"
aria-label="Play {song.title}"
class="w-6 shrink-0 flex items-center justify-center cursor-pointer text-primary"
aria-label="Play {song.title} (in library)"
title="In library — click to play"
>
<span class="group-hover:hidden text-sm text-base-content/50">{position}</span>
<span class="hidden group-hover:block text-primary">
<Play class="w-4 h-4 mx-auto fill-current" />
</span>
<Play class="w-4 h-4 mx-auto fill-current" />
</button>
{:else if previewEnabled}
<span class="w-6 shrink-0 flex items-center justify-center">
@@ -93,6 +119,40 @@
</p>
</div>
</a>
<!--
Action cluster — LidarrRequestButton + TrackDownloadButton.
ALWAYS visible at full opacity, no hover dependency. Hover-to-reveal
UX confused users on mobile (no hover state at all) and on desktop
(per-user preference 2026-05-29). The "already in library" affordance
lives in the button title attribute instead.
-->
<div
class="shrink-0 flex items-center gap-1"
title={canPlay ? 'Already in library — download again only if you need a fresh copy' : ''}
>
{#if buttonVisibility.lidarr_request && song.recording_mbid && song.release_group_mbid}
<LidarrRequestButton
albumMbid={song.release_group_mbid}
trackMbid={song.recording_mbid}
trackTitle={song.title}
trackPosition={song.track_number ?? null}
discNumber={song.disc_number ?? null}
initialStatus={lidarrStatus}
size="sm"
/>
{/if}
{#if buttonVisibility.track_download}
<TrackDownloadButton
artist={song.artist_name}
album={downloadAlbum}
trackTitle={song.title}
trackPosition={song.track_number ?? null}
discNumber={song.disc_number ?? null}
size="sm"
/>
{/if}
</div>
</div>
{:else}
<div
@@ -103,13 +163,11 @@
{#if canPlay}
<button
onclick={onPlay}
class="w-6 shrink-0 flex items-center justify-center cursor-pointer"
aria-label="Play {song.title}"
class="w-6 shrink-0 flex items-center justify-center cursor-pointer text-primary"
aria-label="Play {song.title} (in library)"
title="In library — click to play"
>
<span class="group-hover:hidden text-sm text-base-content/50">{position}</span>
<span class="hidden group-hover:block text-primary">
<Play class="w-4 h-4 mx-auto fill-current" />
</span>
<Play class="w-4 h-4 mx-auto fill-current" />
</button>
{:else if previewEnabled}
<span class="w-6 shrink-0 flex items-center justify-center">
@@ -140,5 +198,22 @@
<p class="font-medium text-sm truncate min-w-0">{song.title}</p>
<p class="text-xs text-base-content/40 truncate min-w-0 text-right italic"></p>
</div>
<!-- Always visible at full opacity, matching the canonical cluster above. -->
<div
class="shrink-0"
title={canPlay ? 'Already in library — download again only if you need a fresh copy' : ''}
>
{#if buttonVisibility.track_download}
<TrackDownloadButton
artist={song.artist_name}
album={downloadAlbum}
trackTitle={song.title}
trackPosition={song.track_number ?? null}
discNumber={song.disc_number ?? null}
size="sm"
/>
{/if}
</div>
</div>
{/if}
@@ -3,6 +3,8 @@
import { createSettingsForm } from '$lib/utils/settingsForm.svelte';
import { invalidateQueriesWithPersister } from '$lib/queries/QueryClient';
import { HomeQueryKeyFactory } from '$lib/queries/HomeQueryKeyFactory';
import { homeSettingsStore } from '$lib/stores/homeSettings.svelte';
import { nowPlayingStore } from '$lib/stores/nowPlayingSessions.svelte';
import { onMount, onDestroy } from 'svelte';
const form = createSettingsForm<HomeSettings>({
@@ -10,6 +12,12 @@
saveEndpoint: '/api/v1/settings/home',
afterSave: async () => {
await invalidateQueriesWithPersister({ queryKey: HomeQueryKeyFactory.prefix });
// Refresh the cross-cutting store so nowPlayingSessions.fetchAll()
// picks up the new value on its next tick — and trigger an
// immediate poll so the banner appears/disappears without the
// user having to wait the 3s interval.
await homeSettingsStore.refresh();
void nowPlayingStore.refresh();
}
});
@@ -55,6 +63,26 @@
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
bind:checked={form.data.show_now_playing}
class="toggle toggle-primary"
/>
<div>
<span class="label-text font-medium">Show currently listening</span>
<p class="text-xs text-base-content/50">
Shows the now-playing banner on the home page, the sidebar listening
indicator, and the active-sessions widget on each library page. Off by
default for shared instances — Plex returns sessions across the whole
server, so leaving it on can leak other household members' listening
activity. Your own playback inside MusicSeerr is always visible.
</p>
</div>
</label>
</div>
{#if form.message}
<div
class="alert"
@@ -8,10 +8,13 @@ vi.mock('$env/dynamic/public', () => ({
import SettingsHome from './SettingsHome.svelte';
function mockHomeSettings(overrides: { show_whats_hot?: boolean } = {}) {
function mockHomeSettings(
overrides: { show_whats_hot?: boolean; show_now_playing?: boolean } = {}
) {
return {
show_whats_hot: true,
show_globally_trending: true,
show_now_playing: false,
cache_ttl_trending: 3600,
cache_ttl_personal: 300,
...overrides
@@ -110,4 +113,38 @@ describe('SettingsHome.svelte', () => {
expect(toggle.checked).toBe(true);
});
});
it('renders Show currently listening toggle', async () => {
globalThis.fetch = mockJsonResponse(mockHomeSettings());
render(SettingsHome);
const label = page.getByText('Show currently listening');
await expect.element(label).toBeInTheDocument();
});
it('currently-listening toggle defaults to the loaded value (off)', async () => {
globalThis.fetch = mockJsonResponse(mockHomeSettings({ show_now_playing: false }));
render(SettingsHome);
await vi.waitFor(() => {
const toggles = document.querySelectorAll(
'input[type="checkbox"].toggle'
) as NodeListOf<HTMLInputElement>;
expect(toggles.length).toBeGreaterThanOrEqual(2);
expect(toggles[1].checked).toBe(false);
});
});
it('currently-listening toggle reflects loaded state when on', async () => {
globalThis.fetch = mockJsonResponse(mockHomeSettings({ show_now_playing: true }));
render(SettingsHome);
await vi.waitFor(() => {
const toggles = document.querySelectorAll(
'input[type="checkbox"].toggle'
) as NodeListOf<HTMLInputElement>;
expect(toggles.length).toBeGreaterThanOrEqual(2);
expect(toggles[1].checked).toBe(true);
});
});
});
@@ -6,7 +6,9 @@
UserPreferences,
ReleaseTypeOption,
LidarrMetadataProfilePreferences,
MetadataProfile
MetadataProfile,
TrackButtonKey,
DownloadOptionsContext
} from '$lib/types';
import { invalidateQueriesWithPersister } from '$lib/queries/QueryClient';
import { ArtistQueryKeyFactory } from '$lib/queries/artist/ArtistQueryKeyFactory';
@@ -14,7 +16,31 @@
let preferences: UserPreferences = $state({
primary_types: [],
secondary_types: [],
release_statuses: []
release_statuses: [],
// Defaults all-on — mirrors backend.TrackButtonVisibility defaults
// and pre-fork behavior. Replaced by the server response on load.
download_options: {
popular_songs: {
lidarr_request: true,
track_download: true,
preview: true,
yt_play: true,
jellyfin: true,
local_files: true,
navidrome: true,
plex: true
},
album_page: {
lidarr_request: true,
track_download: true,
preview: true,
yt_play: true,
jellyfin: true,
local_files: true,
navidrome: true,
plex: true
}
}
});
let saving = $state(false);
let saveMessage = $state('');
@@ -62,6 +88,94 @@
{ id: 'pseudo-release', title: 'Pseudo-Release', description: 'Placeholder or meta releases' }
];
// Per-button metadata for the Download Options table. `contexts` lists
// where the button actually renders today — the toggle is hidden for
// contexts where the button isn't rendered (e.g., the Popular Songs
// row only shows lidarr_request + track_download). Keeping the schema
// carrying all 8 keys in both contexts means a future expansion (e.g.
// adding Plex playback to Popular Songs) needs no migration — just
// flip the `contexts` array here.
type TrackButtonOption = {
key: TrackButtonKey;
title: string;
description: string;
contexts: DownloadOptionsContext[];
};
// "Download" buttons — actions that pull a track into the library.
// TrackDownloadButton already covers both YouTube and Spotify behind
// its own source picker, so it counts as one row regardless of source.
const downloadButtons: TrackButtonOption[] = [
{
key: 'lidarr_request',
title: 'Request via Lidarr',
description: 'Adds the track to Lidarr and triggers a search through your configured indexers.',
contexts: ['popular_songs', 'album_page']
},
{
key: 'track_download',
title: 'Direct download (yt-dlp)',
description:
'Grabs the track via the yt-dlp-worker. The button itself has an internal YouTube / Spotify source picker.',
contexts: ['popular_songs', 'album_page']
}
];
// "Playback" buttons — listen / queue / play, no library write. Each
// is still source-availability-gated (the Jellyfin button only renders
// when a Jellyfin server is configured AND the track is mapped to a
// file there); unchecking here force-hides on top of that gate.
const playbackButtons: TrackButtonOption[] = [
{
key: 'preview',
title: 'YouTube preview',
description: 'Inline preview/scrub of the track on YouTube without leaving the page.',
contexts: ['album_page']
},
{
key: 'yt_play',
title: 'YouTube play',
description: 'Queue the track for playback via the YouTube player.',
contexts: ['album_page']
},
{
key: 'jellyfin',
title: 'Jellyfin',
description: 'Play the track from your Jellyfin server when available.',
contexts: ['album_page']
},
{
key: 'local_files',
title: 'Local files',
description: 'Play the track from the local-files library when available.',
contexts: ['album_page']
},
{
key: 'navidrome',
title: 'Navidrome',
description: 'Play the track from your Navidrome server when available.',
contexts: ['album_page']
},
{
key: 'plex',
title: 'Plex',
description: 'Play the track from your Plex server when available.',
contexts: ['album_page']
}
];
function toggleDownloadOption(context: DownloadOptionsContext, key: TrackButtonKey): void {
// Object-spread re-assignment for Svelte 5 deep-reactivity safety —
// matches the immutable update style used by toggleType above.
preferences.download_options = {
...preferences.download_options,
[context]: {
...preferences.download_options[context],
[key]: !preferences.download_options[context][key]
}
};
}
function toggleType(
category: 'primary_types' | 'secondary_types' | 'release_statuses',
id: string
@@ -308,6 +422,46 @@
</div>
{/snippet}
{#snippet trackButtonTable(buttons: TrackButtonOption[], context: DownloadOptionsContext)}
{@const rows = buttons.filter((b) => b.contexts.includes(context))}
{#if rows.length === 0}
<p class="text-sm text-base-content/60 italic">
(none of these buttons render in this slot today)
</p>
{:else}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th class="w-12 text-center">
<span class="text-xs opacity-60">Show</span>
</th>
<th>Button</th>
<th class="hidden sm:table-cell">Description</th>
</tr>
</thead>
<tbody>
{#each rows as btn (btn.key)}
{@const enabled = preferences.download_options[context][btn.key]}
<tr>
<td class="w-12 text-center">
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
checked={enabled}
onchange={() => toggleDownloadOption(context, btn.key)}
/>
</td>
<td class="font-medium">{btn.title}</td>
<td class="text-base-content/70 hidden sm:table-cell">{btn.description}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/snippet}
<div class="card bg-base-200">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Included Releases</h2>
@@ -387,25 +541,67 @@
<h3 class="text-xl font-semibold mb-4">Release Statuses</h3>
{@render typeTable(releaseStatuses, 'release_statuses')}
</div>
</div>
</div>
<div class="card-actions justify-end items-center gap-4">
{#if saveMessage}
<div
class="alert flex-1"
class:alert-success={saveMessage.includes('success')}
class:alert-error={saveMessage.includes('Failed')}
>
<span>{saveMessage}</span>
</div>
{/if}
<button class="btn btn-primary" onclick={handleSave} disabled={saving}>
{#if saving}
<span class="loading loading-spinner loading-sm"></span>
Saving...
{:else}
Save Settings
{/if}
</button>
<div class="card bg-base-200 mt-6">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Download Options</h2>
<p class="text-base-content/70 mb-6">
Choose which <em>download</em> buttons appear next to each track. Direct download already
covers both YouTube and Spotify via the button's own source picker, so it counts once.
Playback / preview buttons live in the separate card below.
</p>
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4">Album Track List</h3>
{@render trackButtonTable(downloadButtons, 'album_page')}
</div>
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4">Popular Songs</h3>
{@render trackButtonTable(downloadButtons, 'popular_songs')}
</div>
</div>
</div>
<div class="card bg-base-200 mt-6">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Playback Buttons</h2>
<p class="text-base-content/70 mb-6">
Choose which <em>playback</em> buttons appear next to each track. Unchecking forces a button
hidden; checking lets the existing source-availability gate decide (e.g., the Jellyfin button
still only renders when a Jellyfin server is configured and the track is mapped there).
</p>
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4">Album Track List</h3>
{@render trackButtonTable(playbackButtons, 'album_page')}
</div>
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4">Popular Songs</h3>
{@render trackButtonTable(playbackButtons, 'popular_songs')}
</div>
</div>
</div>
<div class="card-actions justify-end items-center gap-4 mt-6">
{#if saveMessage}
<div
class="alert flex-1"
class:alert-success={saveMessage.includes('success')}
class:alert-error={saveMessage.includes('Failed')}
>
<span>{saveMessage}</span>
</div>
{/if}
<button class="btn btn-primary" onclick={handleSave} disabled={saving}>
{#if saving}
<span class="loading loading-spinner loading-sm"></span>
Saving...
{:else}
Save Settings
{/if}
</button>
</div>
@@ -0,0 +1,54 @@
import { get, writable } from 'svelte/store';
import { api } from '$lib/api/client';
import type { HomeSettings } from '$lib/types';
const API = '/api/v1/settings/home';
interface HomeSettingsState extends HomeSettings {
loaded: boolean;
}
const defaults: HomeSettingsState = {
cache_ttl_trending: 3600,
cache_ttl_personal: 300,
show_whats_hot: true,
show_globally_trending: true,
show_now_playing: false,
loaded: false
};
function createHomeSettingsStore() {
const { subscribe, set, update } = writable<HomeSettingsState>(defaults);
let loadPromise: Promise<void> | null = null;
async function load(): Promise<void> {
try {
const settings = await api.global.get<HomeSettings>(API);
update((state) => ({ ...state, ...settings, loaded: true }));
} catch {
update((state) => ({ ...state, loaded: true }));
}
}
return {
subscribe,
load,
refresh: load,
ensureLoaded: async (): Promise<void> => {
const current = get({ subscribe });
if (current.loaded) return;
if (loadPromise) return loadPromise;
loadPromise = load().finally(() => {
loadPromise = null;
});
return loadPromise;
},
get showNowPlaying(): boolean {
return get({ subscribe }).show_now_playing;
},
reset: () => set(defaults)
};
}
export const homeSettingsStore = createHomeSettingsStore();
@@ -0,0 +1,30 @@
/**
* Monotonically-increasing counter that components can subscribe to in order
* to know "the local-library state may have changed; re-fetch anything that
* depends on it."
*
* Mechanism: TrackDownloadButton calls `bump()` on first-seen `status=done`
* for a download. Components that render library-derived state (notably
* TopSongsList's resolveMap) include `libraryRefresh.version` in their
* effect-dependency key, so the effect re-runs when the counter ticks.
*
* Cheap, ephemeral (in-memory, lost on reload), and decoupled the producer
* (download flow) and consumers (any future component showing in-library state)
* don't need to know about each other.
*/
function createLibraryRefreshStore() {
let version = $state(0);
return {
get version(): number {
return version;
},
bump(): void {
version += 1;
}
};
}
export const libraryRefresh = createLibraryRefreshStore();
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { writable } from 'svelte/store';
vi.mock('$env/dynamic/public', () => ({
env: { PUBLIC_API_URL: '' }
}));
// integrationStore needs to be a real Svelte store — nowPlayingSessions uses
// `get(integrationStore)` to decide which sources to poll.
const integrationState = writable({
jellyfin: false,
navidrome: false,
plex: true,
loaded: true
});
vi.mock('$lib/stores/integration', () => ({
integrationStore: integrationState
}));
let showNowPlaying = false;
vi.mock('$lib/stores/homeSettings.svelte', () => ({
homeSettingsStore: {
get showNowPlaying() {
return showNowPlaying;
}
}
}));
describe('nowPlayingStore privacy gate', () => {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.resetModules();
});
it('skips the network when showNowPlaying is false', async () => {
showNowPlaying = false;
const fetchSpy = vi.fn();
globalThis.fetch = fetchSpy;
const { nowPlayingStore } = await import('./nowPlayingSessions.svelte');
await nowPlayingStore.refresh();
expect(fetchSpy).not.toHaveBeenCalled();
expect(nowPlayingStore.sessions.length).toBe(0);
});
it('hits the configured source when showNowPlaying is true', async () => {
showNowPlaying = true;
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ sessions: [] }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
);
globalThis.fetch = fetchSpy;
const { nowPlayingStore } = await import('./nowPlayingSessions.svelte');
await nowPlayingStore.refresh();
// Plex is the only integration configured in the mock above — exactly
// one fetch should land on the Plex sessions endpoint.
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(String(fetchSpy.mock.calls[0][0])).toContain('plex');
});
});
@@ -1,5 +1,6 @@
import { API } from '$lib/constants';
import { integrationStore } from '$lib/stores/integration';
import { homeSettingsStore } from '$lib/stores/homeSettings.svelte';
import { get } from 'svelte/store';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type {
@@ -113,6 +114,17 @@ function createNowPlayingStore() {
async function fetchAll() {
if (typeof document !== 'undefined' && document.hidden) return;
// Privacy gate: when the home setting is off, drop any cached server
// sessions and skip the network fetch entirely. The merged store
// still emits the user's local MusicSeerr playback because that is
// built from playerStore, not from this feed.
if (!homeSettingsStore.showNowPlaying) {
if (sessions.length > 0) sessions = [];
lastGoodSessions.clear();
interpBasis.clear();
return;
}
const integrations = get(integrationStore);
const fetches: Promise<{
source: SourceKey;
+21 -2
View File
@@ -1,13 +1,32 @@
import { writable } from 'svelte/store';
import type { UserPreferences } from '$lib/types';
import type { UserPreferences, TrackButtonVisibility } from '$lib/types';
import { api } from '$lib/api/client';
const API_BASE = '/api/v1';
// All-true visibility — matches backend.TrackButtonVisibility defaults.
// Used as the client-side default so first paint (before /preferences
// resolves) renders the full cluster, matching pre-fork behavior. The
// server's response replaces this on load.
const allVisible: TrackButtonVisibility = {
lidarr_request: true,
track_download: true,
preview: true,
yt_play: true,
jellyfin: true,
local_files: true,
navidrome: true,
plex: true
};
const defaultPreferences: UserPreferences = {
primary_types: ['album', 'ep', 'single'],
secondary_types: ['studio'],
release_statuses: ['official']
release_statuses: ['official'],
download_options: {
popular_songs: { ...allVisible },
album_page: { ...allVisible }
}
};
const { subscribe, set, update } = writable<UserPreferences>(defaultPreferences);
+25
View File
@@ -173,10 +173,34 @@ export type ArtistReleases = {
source_total_count: number | null;
};
// Mirrors backend.TrackButtonVisibility — per-context force-off flags
// for the track-row action cluster. true = let the existing
// source-availability gate decide; false = always hide.
export type TrackButtonVisibility = {
lidarr_request: boolean;
track_download: boolean;
preview: boolean;
yt_play: boolean;
jellyfin: boolean;
local_files: boolean;
navidrome: boolean;
plex: boolean;
};
export type TrackButtonKey = keyof TrackButtonVisibility;
export type DownloadOptions = {
popular_songs: TrackButtonVisibility;
album_page: TrackButtonVisibility;
};
export type DownloadOptionsContext = keyof DownloadOptions;
export type UserPreferences = {
primary_types: string[];
secondary_types: string[];
release_statuses: string[];
download_options: DownloadOptions;
};
export type ReleaseTypeOption = {
@@ -273,6 +297,7 @@ export type HomeSettings = {
cache_ttl_personal: number;
show_whats_hot: boolean;
show_globally_trending: boolean;
show_now_playing: boolean;
};
export type HomeArtist = {
+15 -1
View File
@@ -7,6 +7,7 @@
import { errorModal } from '$lib/stores/errorModal';
import { libraryStore } from '$lib/stores/library';
import { integrationStore } from '$lib/stores/integration';
import { preferencesStore } from '$lib/stores/preferences';
import { initCacheTTLs } from '$lib/stores/cacheTtl';
import { playerStore } from '$lib/stores/player.svelte';
import { launchYouTubePlayback } from '$lib/player/launchYouTubePlayback';
@@ -40,6 +41,7 @@
import { requestCountStore } from '$lib/stores/requestCountStore.svelte';
import { nowPlayingMerged } from '$lib/stores/nowPlayingMerged.svelte';
import { nowPlayingStore } from '$lib/stores/nowPlayingSessions.svelte';
import { homeSettingsStore } from '$lib/stores/homeSettings.svelte';
import SidebarVisualiser from '$lib/components/SidebarVisualiser.svelte';
import { createNavigationProgressController } from '$lib/utils/navigationProgress';
import { fromStore } from 'svelte/store';
@@ -140,12 +142,24 @@
deferInit(() => {
libraryStore.initialize();
void imageSettingsStore.load();
// Pull saved user preferences (download_options, included
// release types, etc.) so components like AlbumTrackList /
// TrackRow see the actual saved values on first paint instead
// of the all-true client defaults. Without this, the prefs
// only loaded when the Settings page mounted, so any other
// page rendered with stale defaults.
void preferencesStore.load();
void restorePlayerSession();
void scrobbleManager.init();
requestCountStore.startPolling();
syncStatus.connect();
});
integrationStore.ensureLoaded().then(() => {
// Load home settings before starting the now-playing poller. The
// poller's fetchAll() gate keys on homeSettingsStore.showNowPlaying;
// without this load, the first 3s tick can leak server sessions
// against the default (which itself defaults False, but if it were
// ever flipped True server-side we want the gate to reflect it).
Promise.all([integrationStore.ensureLoaded(), homeSettingsStore.ensureLoaded()]).then(() => {
nowPlayingStore.start();
});
});
@@ -10,8 +10,10 @@
NavidromeAlbumMatch,
NavidromeTrackInfo,
PlexAlbumMatch,
PlexTrackInfo
PlexTrackInfo,
TrackButtonVisibility
} from '$lib/types';
import { preferencesStore } from '$lib/stores/preferences';
import type { MenuItem } from '$lib/components/ContextMenu.svelte';
import type { RenderedTrackSection } from './albumTrackResolvers';
import { resolveSourceTrack } from './albumTrackResolvers';
@@ -23,6 +25,14 @@
import TrackPlayButton from '$lib/components/TrackPlayButton.svelte';
import TrackPreviewButton from '$lib/components/TrackPreviewButton.svelte';
import TrackSourceButton from '$lib/components/TrackSourceButton.svelte';
import TrackDownloadButton from '$lib/components/TrackDownloadButton.svelte';
import LidarrRequestButton from '$lib/components/LidarrRequestButton.svelte';
import {
getLidarrRequestStatus,
projectButtonStatus,
buildStatusLookup,
type LidarrButtonStatus
} from '$lib/api/lidarrRequest';
import ContextMenu from '$lib/components/ContextMenu.svelte';
import JellyfinIcon from '$lib/components/JellyfinIcon.svelte';
import LocalFilesIcon from '$lib/components/LocalFilesIcon.svelte';
@@ -99,6 +109,80 @@
onQuotaUpdate,
getTrackContextMenuItems
}: Props = $props();
// Lidarr per-track status. Re-fetched + polled on every album change.
//
// IMPORTANT: must use $effect (not onMount) for setup so the lookup
// rebuilds when SvelteKit navigates between albums — SvelteKit reuses
// this component across `/album/A` → `/album/B` transitions, so
// onMount only fires once and a stale Album A lookup would leak into
// Album B's rendering. The position+disc fallback would then collide
// (positions 1,2,3… exist on every album) and every track on Album B
// would falsely render as "requested in Lidarr".
let statusLookup = $state<ReturnType<typeof buildStatusLookup> | null>(null);
$effect(() => {
// Reactive dep: re-run whenever the album mbid changes.
const albumMbid = album?.musicbrainz_id;
if (!albumMbid) {
statusLookup = null;
return;
}
// Reset the lookup BEFORE the fetch lands so buttons default to
// idle during the transition (better than briefly showing the
// previous album's state).
statusLookup = null;
let cancelled = false;
async function refresh() {
try {
const res = await getLidarrRequestStatus(albumMbid);
if (cancelled) return;
statusLookup = buildStatusLookup(albumMbid, res);
} catch {
// Lidarr unreachable or album not in library — leave null.
// Buttons fall back to `none` (idle) and remain clickable.
}
}
refresh();
const handle = setInterval(refresh, 30000);
return () => {
cancelled = true;
clearInterval(handle);
};
});
function statusFor(
recordingId: string | null | undefined,
position: number,
disc: number
): LidarrButtonStatus {
if (!statusLookup) return 'none';
const albumMbid = album?.musicbrainz_id;
if (!albumMbid) return 'none';
return projectButtonStatus(statusLookup.lookup(albumMbid, recordingId, position, disc));
}
// User's per-button visibility prefs for the album-page slot. Each
// existing per-button render-gate (showJellyfinBtn, youtubeEnabled,
// etc.) is AND'd with the matching flag here — unchecked = force-off,
// checked = the existing source-availability gate decides.
let buttonVisibility = $state<TrackButtonVisibility>({
lidarr_request: true,
track_download: true,
preview: true,
yt_play: true,
jellyfin: true,
local_files: true,
navidrome: true,
plex: true
});
preferencesStore.subscribe((prefs) => {
buttonVisibility = prefs.download_options.album_page;
});
</script>
<div class="bg-base-200 rounded-box overflow-visible">
@@ -151,22 +235,32 @@
(playerStore.currentQueueItem?.discNumber ?? 1) === trackDiscNumber &&
playerStore.currentQueueItem?.trackNumber === track.position &&
playerStore.isPlaying}
{@const showJellyfinBtn = jellyfinEnabled && jellyfinMatch?.found}
{@const showLocalBtn = localfilesEnabled && localMatch?.found}
{@const showNavidromeBtn = navidromeEnabled && navidromeMatch?.found}
{@const showPlexBtn = plexEnabled && plexMatch?.found}
{@const showJellyfinBtn = buttonVisibility.jellyfin && jellyfinEnabled && jellyfinMatch?.found}
{@const showLocalBtn = buttonVisibility.local_files && localfilesEnabled && localMatch?.found}
{@const showNavidromeBtn = buttonVisibility.navidrome && navidromeEnabled && navidromeMatch?.found}
{@const showPlexBtn = buttonVisibility.plex && plexEnabled && plexMatch?.found}
{@const hasAnySource =
tl !== null ||
jellyfinTrack !== null ||
localTrack !== null ||
navidromeTrack !== null ||
plexTrack !== null}
{@const showPreview = youtubeApiConfigured && !hasAnySource}
{@const showPreview = buttonVisibility.preview && youtubeApiConfigured && !hasAnySource}
{@const showYtPlay = buttonVisibility.yt_play && youtubeEnabled}
{@const showLidarrRequest = buttonVisibility.lidarr_request && !!track.recording_id}
{@const showTrackDownload = buttonVisibility.track_download}
<li
class="list-row group hover:bg-base-300/50 transition-colors p-3 sm:p-4"
style={isCurrentlyPlaying ? `background-color: ${colors.accent}20;` : ''}
>
<div class="list-col-grow flex items-center gap-4 w-full">
<!--
flex-wrap so the action cluster (up to 9 buttons: preview /
play / 4 source icons / lidarr-request / download / context-menu)
drops to a second line on narrow mobile viewports instead of
squeezing the track title to zero width via the cluster's
shrink-0.
-->
<div class="list-col-grow flex flex-wrap items-center gap-x-4 gap-y-2 w-full">
<div
class="font-medium w-8 text-center shrink-0 {isCurrentlyPlaying
? ''
@@ -180,7 +274,7 @@
{/if}
</div>
<div class="flex-1 min-w-0">
<div class="flex-1 min-w-[10rem]">
<div
class="font-medium truncate"
style={isCurrentlyPlaying ? `color: ${colors.accent};` : ''}
@@ -193,8 +287,7 @@
{formatDuration(track.length)}
</div>
{#if youtubeEnabled || showPreview || showJellyfinBtn || showLocalBtn || showNavidromeBtn || showPlexBtn}
<div class="flex items-center gap-1.5 shrink-0 ml-auto">
<div class="flex flex-wrap items-center gap-1.5 sm:shrink-0 sm:ml-auto justify-end">
{#if showPreview}
<TrackPreviewButton
artist={album.artist_name}
@@ -209,7 +302,7 @@
/>
{/if}
{#if youtubeEnabled}
{#if showYtPlay}
<TrackPlayButton
trackNumber={track.position}
discNumber={trackDiscNumber}
@@ -283,6 +376,29 @@
</TrackSourceButton>
{/if}
{#if showLidarrRequest}
<LidarrRequestButton
albumMbid={album.musicbrainz_id}
trackMbid={track.recording_id}
trackTitle={track.title}
artistMbid={album.artist_id}
trackPosition={track.position}
discNumber={trackDiscNumber}
initialStatus={statusFor(track.recording_id, track.position, trackDiscNumber)}
/>
{/if}
{#if showTrackDownload}
<TrackDownloadButton
artist={album.artist_name}
album={album.title}
trackTitle={track.title}
artistMbid={album.artist_id}
trackPosition={track.position}
discNumber={trackDiscNumber}
/>
{/if}
<div>
<ContextMenu
items={getTrackContextMenuItems(
@@ -297,7 +413,6 @@
/>
</div>
</div>
{/if}
</div>
</li>
{/each}