Personal fork of habirabbu/musicseerr — multi-instance + inline downloads + lidarr-request
Backend CI / Lint (push) Has been cancelled
Backend CI / Tests (push) Has been cancelled

Squashes 26 incremental fork commits (Apr–May 2026) onto upstream main as a single
diff for cleaner cross-fork comparison. Original history preserved on the
pre-squash-backup tag locally.

Feature additions
─────────────────

• Inline single-track download via yt-dlp-worker proxy
  New routes: POST /api/v1/track-download/search (source: youtube | spotify),
  POST /api/v1/track-download, GET /api/v1/track-download/{id}. Frontend
  TrackDownloadButton in album track list AND popular-songs row, with a per-button
  source picker. Per-user rate limits live in the worker's SQLite store. On
  completion the backend fires Lidarr RefreshArtist + Plex library refresh +
  cache invalidation, and the popular-songs list auto-refreshes.

• Per-instance library pinning via MUSICSEERR_LIBRARY env
  Backend stamps the library label server-side (music / music-personal /
  music-shared); clients cannot override. Drives an instance-segregated
  deployment of three musicseerr containers sharing one source tree.

• Lidarr-request flow (single-track requests via Lidarr indexers)
  New routes: POST /api/v1/lidarr-request, GET /api/v1/lidarr-request/status.
  Per-album asyncio.Lock keyed on album_mbid so rapid-clicks on the same album
  serialize correctly. Cross-release track matcher with foreignTrackId →
  foreignRecordingId → position+disc → exact-title → substring fallback chain,
  evaluated per release (recording UUIDs frequently differ between album,
  single, and deluxe edition releases of the same song). Flips
  artist.monitored = True on request so Lidarr's WantedAlbums query reaches
  the track. Full Lidarr-chain gate (artist AND album AND track) for the
  status endpoint to avoid false-positive REQUESTED display. Persistent UI
  state so button icons survive refresh and cross-album navigation.

• Privacy: show_now_playing toggle in Settings → Home
  Default off. Plex /status/sessions returns active audio sessions across the
  whole server with no library-section filter, so a shared instance leaks
  every household member's listening activity. The merged store still emits
  the user's local MusicSeerr playback bar; only server-derived sessions
  (Plex / Jellyfin / Navidrome) are gated.

• Per-button visibility prefs for the track-row action cluster
  Settings → Preferences → Download Options / Playback Buttons. Per-context
  (popular_songs / album_page) force-off flags layered on top of the existing
  source-availability gate.

• UX: wrap action cluster on mobile, hide LidarrRequestButton in tight
  layouts, cross-album status-leak fix in AlbumTrackList ($effect keyed on
  album.musicbrainz_id to rebuild lookup; map keyed by
  "{albumMbid}:{position}:{disc}").

Test coverage
─────────────

Backend pytest: full suite green (2031/2031 as of squash). New: schema-default
tests for HomeSettings, lidarr_request_service cross-release matcher
regression test, singleton-registry expected-count bump to 59. Frontend
vitest: SettingsHome.svelte.spec covers new toggle, nowPlayingSessions
.svelte.spec covers the privacy gate (no fetch when off; fetches when on).
This commit is contained in:
2026-05-29 23:43:05 +00:00
parent e70a76f489
commit 23c9125ad8
47 changed files with 3208 additions and 79 deletions
+52
View File
@@ -0,0 +1,52 @@
"""Single-track Lidarr request API. Fork-only addition.
Distinct from track_download (yt-dlp-worker proxy). This route asks the
configured Lidarr instance to add the album and grab JUST the requested
track via its native indexer pipeline, relying on the track-monitored
fork to gate the import so siblings don't land on disk.
Requires LIDARR_URL pointed at a fork instance (currently
lidarr-shared on gnat:8688).
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from api.v1.schemas.lidarr_request import (
LidarrRequestAccepted,
LidarrRequestRequest,
LidarrRequestStatusResponse,
)
from core.dependencies import get_lidarr_request_service
from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute
from services.lidarr_request_service import LidarrRequestService
router = APIRouter(
route_class=MsgSpecRoute,
prefix="/lidarr-request",
tags=["lidarr-request"],
)
@router.post("", response_model=LidarrRequestAccepted, status_code=202)
async def request_track_via_lidarr(
body: LidarrRequestRequest = MsgSpecBody(LidarrRequestRequest),
service: LidarrRequestService = Depends(get_lidarr_request_service),
) -> LidarrRequestAccepted:
return await service.request_track(
album_mbid=body.album_mbid,
track_mbid=body.track_mbid,
artist_mbid=body.artist_mbid,
track_position=body.track_position,
disc_number=body.disc_number,
track_title=body.track_title,
)
@router.get("/status", response_model=LidarrRequestStatusResponse)
async def get_lidarr_request_status(
album_mbid: str = Query(..., description="Album MusicBrainz release-group ID"),
service: LidarrRequestService = Depends(get_lidarr_request_service),
) -> LidarrRequestStatusResponse:
return await service.get_status(album_mbid)
+42
View File
@@ -10,12 +10,48 @@ from api.v1.schemas.stream import (
StopReportRequest,
)
from core.dependencies import (
get_cache,
get_jellyfin_playback_service,
get_local_files_service,
get_navidrome_playback_service,
get_plex_playback_service,
)
from core.exceptions import ExternalServiceError, PlaybackNotAllowedError, ResourceNotFoundError
# Cache prefixes cleared on stream 404. Must include the upstream Lidarr
# caches too — clearing only source_resolution leaves album-details + tracks
# cached for 5 more min, so the next resolve re-fetches but still gets stale
# Lidarr data back. Mirrors _DOWNLOAD_COMPLETE_CACHE_PREFIXES in
# services/track_download_service.py.
_SELF_HEAL_CACHE_PREFIXES = (
"source_resolution",
"lidarr_album_details:",
"lidarr_album_tracks:",
"lidarr_album_trackfiles_raw:",
"lidarr_artist_albums:",
"lidarr_artist_details:",
)
async def _invalidate_resolve_cache_on_404(track_id: int) -> None:
"""Best-effort: when stream returns 404 due to a stale Lidarr track_file_id
or a path-on-disk mismatch, clear all the caches whose stale state could
keep replaying the same wrong answer. Next /resolve-tracks call hits Lidarr
fresh. Self-healing — user only sees the 404 once per affected album."""
try:
cache = get_cache()
total = 0
for prefix in _SELF_HEAL_CACHE_PREFIXES:
try:
total += await cache.clear_prefix(prefix)
except Exception: # noqa: BLE001, S110
pass
logger.warning(
"stream/local/%d 404 → self-healed: cleared %d cache entries across %d prefixes",
track_id, total, len(_SELF_HEAL_CACHE_PREFIXES),
)
except Exception as e: # noqa: BLE001
logger.debug("cache self-heal failed: %s", e)
from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute
from services.jellyfin_playback_service import JellyfinPlaybackService
from services.local_files_service import LocalFilesService
@@ -168,8 +204,14 @@ async def stream_local_file(
media_type=headers.get("Content-Type", "application/octet-stream"),
)
except ResourceNotFoundError:
# Lidarr renumbers track_file_ids on rescans/imports. Invalidate the
# source_resolution cache so the next /resolve-tracks gets fresh IDs.
await _invalidate_resolve_cache_on_404(track_id)
raise HTTPException(status_code=404, detail="Track file not found")
except FileNotFoundError:
# Lidarr has a track_file record but its path doesn't exist on disk
# (drive swap residue, manual file delete, etc.). Same fix: bust cache.
await _invalidate_resolve_cache_on_404(track_id)
raise HTTPException(status_code=404, detail="Track file not found on disk")
except PermissionError:
raise HTTPException(status_code=403, detail="Access denied: path is outside the music directory")
+60
View File
@@ -0,0 +1,60 @@
"""Inline track-download API. Fork-only addition.
Proxies to the yt-dlp-worker sidecar on gnat. The library label is fixed by
backend env (MUSICSEERR_LIBRARY) — clients cannot override it.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from api.v1.schemas.track_download import (
TrackDownloadAccepted,
TrackDownloadJobStatus,
TrackDownloadRequest,
TrackDownloadSearchRequest,
TrackDownloadSearchResponse,
)
from core.dependencies import get_track_download_service
from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute
from services.track_download_service import TrackDownloadService
router = APIRouter(
route_class=MsgSpecRoute,
prefix="/track-download",
tags=["track-download"],
)
@router.post("/search", response_model=TrackDownloadSearchResponse)
async def search_candidates(
body: TrackDownloadSearchRequest = MsgSpecBody(TrackDownloadSearchRequest),
service: TrackDownloadService = Depends(get_track_download_service),
) -> TrackDownloadSearchResponse:
return await service.search(query=body.query, limit=body.limit, source=body.source)
@router.post("", response_model=TrackDownloadAccepted, status_code=202)
async def request_track_download(
body: TrackDownloadRequest = MsgSpecBody(TrackDownloadRequest),
service: TrackDownloadService = Depends(get_track_download_service),
) -> TrackDownloadAccepted:
return await service.request_download(
video_id=body.video_id,
source=body.source,
target_duration_seconds=body.target_duration_seconds,
artist=body.artist,
album=body.album,
track_title=body.track_title,
artist_mbid=body.artist_mbid,
track_position=body.track_position,
disc_number=body.disc_number,
)
@router.get("/{job_id}", response_model=TrackDownloadJobStatus)
async def get_track_download_job(
job_id: str,
service: TrackDownloadService = Depends(get_track_download_service),
) -> TrackDownloadJobStatus:
return await service.get_job(job_id)
+96
View File
@@ -0,0 +1,96 @@
"""Schemas for the inline single-track Lidarr request feature.
Fork-only addition. Distinct from track_download.py (which proxies to
the yt-dlp-worker on gnat for YouTube/Spotify-resolved downloads).
This feature uses Lidarr's native indexer + download-client pipeline
(slskd / qBittorrent / etc.) to grab the requested track, leveraging
the track-monitored fork's PUT /track/monitor endpoint so that only
the requested track ends up on disk — sibling tracks in the same album
release are rejected at import by TrackMonitoredSpecification.
Requires lidarr_url to point at an instance running shaunrd0/Lidarr
(currently only lidarr-shared on gnat:8688).
"""
from __future__ import annotations
from infrastructure.msgspec_fastapi import AppStruct
class LidarrRequestRequest(AppStruct):
"""Inbound request from the frontend.
album_mbid and track_mbid are required — they drive Lidarr's album
lookup/add and the per-track identification.
artist_mbid is optional and only used for diagnostic logging; Lidarr's
album lookup already returns the artist MBID server-side and that's
what we actually use for the add. Frontend callers without artist
context (TopSongsList, etc.) can leave it null.
"""
album_mbid: str
track_mbid: str
artist_mbid: str | None = None
# Fallback for matching when track_mbid alone doesn't disambiguate.
# Backend matcher tries them in order: foreignTrackId == track_mbid,
# foreignRecordingId == track_mbid, position+disc, then track_title.
# Frontend should send what it has — Popular Songs lists often lack
# position/disc, album-detail pages have all four.
track_position: int | None = None
disc_number: int | None = None
track_title: str | None = None
class LidarrRequestAccepted(AppStruct):
"""Returned 202 from the request endpoint.
`command_id` is Lidarr's command queue ID for the AlbumSearch — clients
can poll `/api/v1/command/{id}` against Lidarr directly if they want
progress, but for now the UI just shows success/error and lets the
download appear in the library when Lidarr finishes.
"""
status: str
album_id: int
album_title: str
track_id: int
track_title: str
other_tracks_unmonitored: int
command_id: int | None = None
note: str | None = None
class LidarrRequestTrackStatus(AppStruct):
"""Per-track status surfaced by GET /api/v1/lidarr-request/status.
The frontend uses this to render the LidarrRequestButton in the right
persistent state — checkmark for downloaded, hourglass for requested-
but-not-yet-downloaded, idle for not-requested. Without this, the
button only knows transient session state and forgets after refresh.
position + disc_number are returned alongside recording_mbid because
Lidarr's foreignRecordingId doesn't always equal MusicBrainz's
recording_id (Lidarr sometimes maps to a different variant). The
frontend prefers recording_mbid match but falls back to position+disc
when recording_mbid lookup misses.
"""
recording_mbid: str
position: int
disc_number: int
monitored: bool
has_file: bool
class LidarrRequestStatusResponse(AppStruct):
"""Returned by GET /api/v1/lidarr-request/status?album_mbid=X.
in_library = album exists in Lidarr at all. If false, no tracks are
requested (the button shows idle on every row). If true, walk `tracks`
to find the per-track status keyed by recording_mbid.
"""
in_library: bool
tracks: list[LidarrRequestTrackStatus]
+50 -7
View File
@@ -62,10 +62,45 @@ class LastFmAuthSessionResponse(AppStruct):
username: str = ""
class TrackButtonVisibility(AppStruct):
"""Per-context visibility flags for the track-row action cluster.
Each flag is a force-off: when False, the corresponding button is
suppressed even if its underlying source is configured. When True,
the existing source-availability gate applies (e.g., the Jellyfin
button still only shows when a jellyfin server is configured and
the track is mapped to a file there).
Default all True — preserves pre-fork behavior, so users with no
`download_options` key in config.json see no change after upgrade.
The same shape is reused for both the Popular Songs row (which today
only renders `lidarr_request` and `track_download`) and the Album
page row (which renders the full cluster). Carrying all flags in
both contexts means a future expansion to e.g. show Plex playback
next to Popular Songs needs no schema migration.
"""
lidarr_request: bool = True
track_download: bool = True
preview: bool = True
yt_play: bool = True
jellyfin: bool = True
local_files: bool = True
navidrome: bool = True
plex: bool = True
class DownloadOptions(AppStruct):
popular_songs: TrackButtonVisibility = msgspec.field(default_factory=TrackButtonVisibility)
album_page: TrackButtonVisibility = msgspec.field(default_factory=TrackButtonVisibility)
class UserPreferences(AppStruct):
primary_types: list[str] = msgspec.field(default_factory=lambda: ["album", "ep", "single"])
secondary_types: list[str] = msgspec.field(default_factory=lambda: ["studio"])
release_statuses: list[str] = msgspec.field(default_factory=lambda: ["official"])
download_options: DownloadOptions = msgspec.field(default_factory=DownloadOptions)
class LidarrConnectionSettings(AppStruct):
@@ -171,18 +206,26 @@ class HomeSettings(AppStruct):
cache_ttl_personal: int = 300
show_whats_hot: bool = True
show_globally_trending: bool = True
def __post_init__(self) -> None:
if self.cache_ttl_trending < 300 or self.cache_ttl_trending > 86400:
raise msgspec.ValidationError("cache_ttl_trending must be between 300 and 86400")
if self.cache_ttl_personal < 60 or self.cache_ttl_personal > 3600:
raise msgspec.ValidationError("cache_ttl_personal must be between 60 and 3600")
# Defaults False because Plex /status/sessions returns ALL active audio
# streams across the whole server with no library-section filter — on a
# shared instance that means anyone hitting the UI sees what every other
# household member is listening to. Local MusicSeerr playback (the user's
# own tab) is unaffected; this only gates the server-derived feed used
# by HomeSectionNowPlaying, SidebarVisualiser, and the /library/* pages.
show_now_playing: bool = False
class LocalFilesConnectionSettings(AppStruct):
enabled: bool = False
music_path: str = "/music"
lidarr_root_path: str = "/music"
# Lidarr's container-internal root path — the prefix musicseerr strips
# from Lidarr-returned track paths before joining with music_path. Must
# match Lidarr's /data convention (LSIO + hotio *arr images all mount
# /data); the upstream default of /music was wrong for any deployment
# that pairs musicseerr with a real Lidarr instance. Symptom of the
# wrong value: /api/v1/stream/local/<id> returns 404 because the remap
# produces /music/data/<artist>/... which doesn't exist.
lidarr_root_path: str = "/data"
class LocalFilesVerifyResponse(AppStruct):
+78
View File
@@ -0,0 +1,78 @@
"""Schemas for the inline track-download feature.
This is a fork-only addition. Requests are proxied to a yt-dlp-worker sidecar
on gnat which performs the actual yt-dlp call and post-download Lidarr/Plex
triggers. The library label (music | music-personal) is stamped server-side
from the MUSICSEERR_LIBRARY env var so that public musicseerr cannot drop
files into the personal library.
Search source: "youtube" (default; free-text yt-dlp search) or "spotify"
(Spotify Web API search; the worker resolves the chosen Spotify track to a
matching YouTube video at download time, transparently to the client).
"""
from __future__ import annotations
from typing import Literal
from infrastructure.msgspec_fastapi import AppStruct
SearchSource = Literal["youtube", "spotify"]
class TrackDownloadSearchRequest(AppStruct):
query: str
limit: int = 5
source: SearchSource = "youtube"
class TrackDownloadCandidate(AppStruct):
# video_id is a YouTube video ID when source="youtube" and a Spotify
# track ID when source="spotify". The worker disambiguates by source.
video_id: str
url: str
title: str
source: SearchSource = "youtube"
channel: str | None = None
artist: str | None = None # populated for source="spotify"
album: str | None = None # populated for source="spotify"
duration_seconds: int | None = None
thumbnail_url: str | None = None
class TrackDownloadSearchResponse(AppStruct):
candidates: list[TrackDownloadCandidate]
class TrackDownloadRequest(AppStruct):
"""Inbound request from the frontend. The library param is intentionally
NOT included here — the backend stamps it from env config so the public
musicseerr instance cannot target the personal library."""
video_id: str
artist: str
album: str
track_title: str
source: SearchSource = "youtube"
target_duration_seconds: int | None = None # passed through for spotify→yt resolution
artist_mbid: str | None = None
track_position: int | None = None
disc_number: int | None = None
class TrackDownloadAccepted(AppStruct):
job_id: str
class TrackDownloadJobStatus(AppStruct):
id: str
status: str
artist: str
album: str
track_title: str
library: str
created_at: str
updated_at: str
file_path: str | None = None
error: str | None = None