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(