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
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:
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
Vendored
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user