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
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:
@@ -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
|
||||
Reference in New Issue
Block a user