Files
musicseerr/backend/tests/test_local_files_fallback.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

95 lines
3.1 KiB
Python

"""Tests for LocalFilesService stale-while-error fallback."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from core.exceptions import ExternalServiceError
def _make_local_files_service(lidarr=None, cache=None):
from services.local_files_service import LocalFilesService
lidarr = lidarr or AsyncMock()
prefs = MagicMock()
prefs.get_advanced_settings.return_value = MagicMock(
cache_ttl_local_files_recently_added=120,
cache_ttl_local_files_storage_stats=300,
)
prefs.get_local_files_connection.return_value = MagicMock(
music_path="/music", lidarr_root_path="/data"
)
cache = cache or AsyncMock()
return LocalFilesService(
lidarr_repo=lidarr,
preferences_service=prefs,
cache=cache,
)
class TestStaleWhileError:
@pytest.mark.asyncio
async def test_serves_stale_data_when_lidarr_down(self):
stale_albums = [{"id": 1, "title": "Old Album"}]
cache = AsyncMock()
# Primary cache miss, then stale cache hit
cache.get = AsyncMock(side_effect=lambda key: (
None if key == "local_files_all_albums" else stale_albums
))
lidarr = AsyncMock()
lidarr.get_all_albums = AsyncMock(side_effect=ExternalServiceError("Lidarr down"))
svc = _make_local_files_service(lidarr=lidarr, cache=cache)
result = await svc._fetch_all_albums()
assert result == stale_albums
@pytest.mark.asyncio
async def test_raises_when_no_stale_data(self):
cache = AsyncMock()
cache.get = AsyncMock(return_value=None) # Both caches miss
lidarr = AsyncMock()
lidarr.get_all_albums = AsyncMock(side_effect=ExternalServiceError("Lidarr down"))
svc = _make_local_files_service(lidarr=lidarr, cache=cache)
with pytest.raises(ExternalServiceError, match="Lidarr down"):
await svc._fetch_all_albums()
@pytest.mark.asyncio
async def test_successful_fetch_updates_stale_cache(self):
fresh_albums = [{"id": 2, "title": "Fresh Album"}]
cache = AsyncMock()
cache.get = AsyncMock(return_value=None)
cache.set = AsyncMock()
lidarr = AsyncMock()
lidarr.get_all_albums = AsyncMock(return_value=fresh_albums)
svc = _make_local_files_service(lidarr=lidarr, cache=cache)
result = await svc._fetch_all_albums()
assert result == fresh_albums
# Should have set both primary and stale caches
assert cache.set.call_count == 2
calls = {call.args[0] for call in cache.set.call_args_list}
assert "local_files_all_albums" in calls
assert "local_files_all_albums:stale" in calls
@pytest.mark.asyncio
async def test_cache_hit_returns_without_lidarr_call(self):
cached = [{"id": 3, "title": "Cached"}]
cache = AsyncMock()
cache.get = AsyncMock(return_value=cached)
lidarr = AsyncMock()
lidarr.get_all_albums = AsyncMock()
svc = _make_local_files_service(lidarr=lidarr, cache=cache)
result = await svc._fetch_all_albums()
assert result == cached
lidarr.get_all_albums.assert_not_called()