Files
musicseerr/backend/api/v1/routes/stream.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

341 lines
14 KiB
Python

import logging
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from api.v1.schemas.stream import (
PlaybackSessionResponse,
ProgressReportRequest,
StartPlaybackRequest,
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
from services.navidrome_playback_service import NavidromePlaybackService
from services.plex_playback_service import PlexPlaybackService
logger = logging.getLogger(__name__)
router = APIRouter(route_class=MsgSpecRoute, prefix="/stream", tags=["streaming"])
@router.get("/jellyfin/{item_id}")
async def stream_jellyfin_audio(
item_id: str,
request: Request,
playback_service: JellyfinPlaybackService = Depends(get_jellyfin_playback_service),
) -> StreamingResponse:
try:
range_header = request.headers.get("Range")
return await playback_service.proxy_stream(item_id, range_header=range_header)
except ResourceNotFoundError:
raise HTTPException(status_code=404, detail="Audio item not found")
except PlaybackNotAllowedError as e:
logger.warning("Playback not allowed for %s: %s", item_id, e)
raise HTTPException(status_code=403, detail="Playback not allowed")
except ExternalServiceError as e:
if "416" in str(e):
raise HTTPException(status_code=416, detail="Range not satisfiable")
raise HTTPException(status_code=502, detail="Failed to stream from Jellyfin")
@router.head("/jellyfin/{item_id}")
async def head_jellyfin_audio(
item_id: str,
playback_service: JellyfinPlaybackService = Depends(get_jellyfin_playback_service),
) -> Response:
try:
return await playback_service.proxy_head(item_id)
except ResourceNotFoundError:
raise HTTPException(status_code=404, detail="Audio item not found")
except PlaybackNotAllowedError as e:
logger.warning("Playback not allowed for %s: %s", item_id, e)
raise HTTPException(status_code=403, detail="Playback not allowed")
except ExternalServiceError as e:
logger.error("Jellyfin head stream error for %s: %s", item_id, e)
raise HTTPException(status_code=502, detail="Failed to resolve Jellyfin stream")
@router.post("/jellyfin/{item_id}/start", response_model=PlaybackSessionResponse)
async def start_jellyfin_playback(
item_id: str,
body: StartPlaybackRequest | None = Body(default=None),
playback_service: JellyfinPlaybackService = Depends(get_jellyfin_playback_service),
) -> PlaybackSessionResponse:
try:
play_session_id = await playback_service.start_playback(
item_id,
play_session_id=body.play_session_id if body else None,
)
return PlaybackSessionResponse(play_session_id=play_session_id, item_id=item_id)
except ResourceNotFoundError:
raise HTTPException(status_code=404, detail="Item not found")
except PlaybackNotAllowedError as e:
logger.warning("Playback not allowed for %s: %s", item_id, e)
raise HTTPException(status_code=403, detail="Playback not allowed")
except ExternalServiceError as e:
logger.error("Failed to start playback for %s: %s", item_id, e)
raise HTTPException(status_code=502, detail="Failed to start Jellyfin playback")
@router.post("/jellyfin/{item_id}/progress", status_code=204)
async def report_jellyfin_progress(
item_id: str,
body: ProgressReportRequest = MsgSpecBody(ProgressReportRequest),
playback_service: JellyfinPlaybackService = Depends(get_jellyfin_playback_service),
) -> Response:
try:
await playback_service.report_progress(
item_id=item_id,
play_session_id=body.play_session_id,
position_seconds=body.position_seconds,
is_paused=body.is_paused,
)
return Response(status_code=204)
except ExternalServiceError as e:
logger.warning("Progress report failed for %s: %s", item_id, e)
raise HTTPException(status_code=502, detail="Failed to report progress")
@router.post("/jellyfin/{item_id}/stop", status_code=204)
async def stop_jellyfin_playback(
item_id: str,
body: StopReportRequest = MsgSpecBody(StopReportRequest),
playback_service: JellyfinPlaybackService = Depends(get_jellyfin_playback_service),
) -> Response:
try:
await playback_service.stop_playback(
item_id=item_id,
play_session_id=body.play_session_id,
position_seconds=body.position_seconds,
)
return Response(status_code=204)
except ExternalServiceError as e:
logger.warning("Stop report failed for %s: %s", item_id, e)
raise HTTPException(status_code=502, detail="Failed to report playback stop")
@router.head("/local/{track_id}")
async def head_local_file(
track_id: int,
local_service: LocalFilesService = Depends(get_local_files_service),
) -> Response:
try:
headers = await local_service.head_track(track_id)
return Response(
status_code=200,
headers=headers,
media_type=headers.get("Content-Type", "application/octet-stream"),
)
except ResourceNotFoundError:
raise HTTPException(status_code=404, detail="Track file not found")
except FileNotFoundError:
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")
except ExternalServiceError as e:
logger.error("Local head error for track %s: %s", track_id, e)
raise HTTPException(status_code=502, detail="Failed to check local file")
except OSError as e:
logger.error("OS error checking local track %s: %s", track_id, e)
raise HTTPException(status_code=500, detail="Failed to read local file")
@router.get("/local/{track_id}")
async def stream_local_file(
track_id: int,
request: Request,
local_service: LocalFilesService = Depends(get_local_files_service),
) -> StreamingResponse:
try:
range_header = request.headers.get("Range")
chunks, headers, status_code = await local_service.stream_track(
track_file_id=track_id,
range_header=range_header,
)
return StreamingResponse(
content=chunks,
status_code=status_code,
headers=headers,
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")
except ExternalServiceError as e:
detail = str(e)
if "Range not satisfiable" in detail:
raise HTTPException(status_code=416, detail="Range not satisfiable")
logger.error("Local stream error for track %s: %s", track_id, e)
raise HTTPException(status_code=502, detail="Failed to stream local file")
except OSError as e:
logger.error("OS error streaming local track %s: %s", track_id, e)
raise HTTPException(status_code=500, detail="Failed to read local file")
@router.head("/navidrome/{item_id}")
async def head_navidrome_audio(
item_id: str,
playback_service: NavidromePlaybackService = Depends(get_navidrome_playback_service),
) -> Response:
try:
return await playback_service.proxy_head(item_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid stream request")
except ExternalServiceError:
raise HTTPException(status_code=502, detail="Failed to stream from Navidrome")
@router.get("/navidrome/{item_id}")
async def stream_navidrome_audio(
item_id: str,
request: Request,
playback_service: NavidromePlaybackService = Depends(get_navidrome_playback_service),
) -> StreamingResponse:
try:
return await playback_service.proxy_stream(item_id, request.headers.get("Range"))
except ValueError:
raise HTTPException(status_code=400, detail="Invalid stream request")
except ExternalServiceError as e:
detail = str(e)
if "416" in detail or "Range not satisfiable" in detail:
raise HTTPException(status_code=416, detail="Range not satisfiable")
raise HTTPException(status_code=502, detail="Failed to stream from Navidrome")
@router.post("/navidrome/{item_id}/scrobble")
async def scrobble_navidrome(
item_id: str,
playback_service: NavidromePlaybackService = Depends(get_navidrome_playback_service),
) -> dict[str, str]:
ok = await playback_service.scrobble(item_id)
return {"status": "ok" if ok else "error"}
@router.post("/navidrome/{item_id}/now-playing")
async def navidrome_now_playing(
item_id: str,
playback_service: NavidromePlaybackService = Depends(get_navidrome_playback_service),
) -> dict[str, str]:
ok = await playback_service.report_now_playing(item_id)
return {"status": "ok" if ok else "error"}
@router.post("/navidrome/{item_id}/stopped")
async def navidrome_stopped(
item_id: str,
playback_service: NavidromePlaybackService = Depends(get_navidrome_playback_service),
) -> dict[str, str]:
await playback_service.clear_now_playing(item_id)
return {"status": "ok"}
@router.head("/plex/{part_key:path}")
async def head_plex_audio(
part_key: str,
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
) -> Response:
try:
return await playback_service.proxy_head(part_key)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid stream request")
except ExternalServiceError:
raise HTTPException(status_code=502, detail="Failed to stream from Plex")
@router.get("/plex/{part_key:path}")
async def stream_plex_audio(
part_key: str,
request: Request,
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
) -> StreamingResponse:
try:
return await playback_service.proxy_stream(part_key, request.headers.get("Range"))
except ValueError:
raise HTTPException(status_code=400, detail="Invalid stream request")
except ExternalServiceError as e:
detail = str(e)
if "416" in detail or "Range not satisfiable" in detail:
raise HTTPException(status_code=416, detail="Range not satisfiable")
raise HTTPException(status_code=502, detail="Failed to stream from Plex")
@router.post("/plex/{rating_key}/scrobble")
async def scrobble_plex(
rating_key: str,
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
) -> dict[str, str]:
ok = await playback_service.scrobble(rating_key)
return {"status": "ok" if ok else "error"}
@router.post("/plex/{rating_key}/now-playing")
async def plex_now_playing(
rating_key: str,
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
) -> dict[str, str]:
ok = await playback_service.report_now_playing(rating_key)
return {"status": "ok" if ok else "error"}
@router.post("/plex/{rating_key}/stopped")
async def plex_stopped(
rating_key: str,
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
) -> dict[str, str]:
ok = await playback_service.report_stopped(rating_key)
return {"status": "ok" if ok else "error"}