Files
musicseerr/backend/tests/services/test_lidarr_request_service.py
T
shaunrd0 23c9125ad8
Backend CI / Lint (push) Waiting to run
Backend CI / Tests (push) Waiting to run
Personal fork of habirabbu/musicseerr — multi-instance + inline downloads + lidarr-request
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).
2026-05-29 23:55:54 +00:00

204 lines
7.8 KiB
Python

"""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]