23c9125ad8
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).
484 lines
21 KiB
Python
484 lines
21 KiB
Python
"""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
|