Personal fork of habirabbu/musicseerr — multi-instance + inline downloads + lidarr-request
Backend CI / Lint (push) Waiting to run
Backend CI / Tests (push) Waiting to run
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).
@@ -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
|
||||
@@ -67,6 +67,52 @@ class Settings(BaseSettings):
|
||||
audiodb_premium: bool = Field(default=False, description="Set to true if using a premium AudioDB API key")
|
||||
instance_id: str = Field(default="", description="Auto-generated per-instance UUID for User-Agent differentiation")
|
||||
|
||||
# Inline track-download feature (fork-only). Stamped per musicseerr instance via env:
|
||||
# public musicseerr -> "music"; admin musicseerr-personal -> "music-personal";
|
||||
# public musicseerr-shared -> "music-shared". Backend rejects any other value
|
||||
# and the client cannot override it.
|
||||
musicseerr_library: str = Field(
|
||||
default="music",
|
||||
description="Target library for track downloads: 'music', 'music-personal', or 'music-shared'.",
|
||||
)
|
||||
yt_dlp_worker_url: str = Field(
|
||||
default="http://yt-dlp-worker:4949",
|
||||
description="Base URL for the yt-dlp-worker sidecar that performs single-track downloads.",
|
||||
)
|
||||
|
||||
# Plex notification on download-complete (fork-only). When all three are set,
|
||||
# the track-download flow fires `/library/sections/<id>/refresh` after each
|
||||
# successful download so Plex picks up the new file immediately. Per-instance:
|
||||
# musicseerr -> section 3, musicseerr-personal -> 6, musicseerr-shared -> 7.
|
||||
# Empty = feature disabled (download still works, Plex just won't auto-scan).
|
||||
plex_url: str = Field(
|
||||
default="",
|
||||
description="Plex base URL for post-download library-section refresh. Empty disables the integration.",
|
||||
)
|
||||
plex_token: str = Field(
|
||||
default="",
|
||||
description="Plex authentication token (X-Plex-Token header).",
|
||||
)
|
||||
plex_section_id: int = Field(
|
||||
default=0,
|
||||
description="Plex library section ID matching this musicseerr's library (3/6/7 in our deployment).",
|
||||
)
|
||||
|
||||
@field_validator("musicseerr_library")
|
||||
@classmethod
|
||||
def validate_musicseerr_library(cls, v: str) -> str:
|
||||
normalised = v.strip().lower()
|
||||
if normalised not in {"music", "music-personal", "music-shared"}:
|
||||
raise ValueError(
|
||||
f"musicseerr_library must be 'music', 'music-personal', or 'music-shared'; got '{v}'"
|
||||
)
|
||||
return normalised
|
||||
|
||||
@field_validator("yt_dlp_worker_url")
|
||||
@classmethod
|
||||
def validate_worker_url(cls, v: str) -> str:
|
||||
return v.rstrip("/")
|
||||
|
||||
@field_validator("log_level")
|
||||
@classmethod
|
||||
def validate_log_level(cls, v: str) -> str:
|
||||
|
||||
@@ -70,6 +70,8 @@ from .service_providers import ( # noqa: F401
|
||||
get_plex_library_service,
|
||||
get_plex_playback_service,
|
||||
get_version_service,
|
||||
get_track_download_service,
|
||||
get_lidarr_request_service,
|
||||
)
|
||||
|
||||
from .type_aliases import ( # noqa: F401
|
||||
@@ -120,6 +122,7 @@ from .type_aliases import ( # noqa: F401
|
||||
CacheStatusServiceDep,
|
||||
GitHubRepositoryDep,
|
||||
VersionServiceDep,
|
||||
TrackDownloadServiceDep,
|
||||
)
|
||||
|
||||
from .cleanup import ( # noqa: F401
|
||||
|
||||
@@ -657,3 +657,27 @@ def get_version_service() -> "VersionService":
|
||||
|
||||
github_repo = get_github_repository()
|
||||
return VersionService(github_repo)
|
||||
|
||||
|
||||
@singleton
|
||||
def get_track_download_service() -> "TrackDownloadService":
|
||||
from core.config import get_settings
|
||||
from services.track_download_service import TrackDownloadService
|
||||
|
||||
settings = get_settings()
|
||||
return TrackDownloadService(
|
||||
worker_url=settings.yt_dlp_worker_url,
|
||||
library=settings.musicseerr_library,
|
||||
lidarr_repository=get_lidarr_repository(),
|
||||
memory_cache=get_cache(),
|
||||
plex_url=settings.plex_url,
|
||||
plex_token=settings.plex_token,
|
||||
plex_section_id=settings.plex_section_id,
|
||||
)
|
||||
|
||||
|
||||
@singleton
|
||||
def get_lidarr_request_service() -> "LidarrRequestService":
|
||||
from services.lidarr_request_service import LidarrRequestService
|
||||
|
||||
return LidarrRequestService(lidarr_repository=get_lidarr_repository())
|
||||
|
||||
@@ -53,6 +53,7 @@ from services.lastfm_auth_service import LastFmAuthService
|
||||
from services.scrobble_service import ScrobbleService
|
||||
from services.cache_status_service import CacheStatusService
|
||||
from services.version_service import VersionService
|
||||
from services.track_download_service import TrackDownloadService
|
||||
|
||||
from .cache_providers import (
|
||||
get_cache,
|
||||
@@ -105,6 +106,7 @@ from .service_providers import (
|
||||
get_plex_library_service,
|
||||
get_plex_playback_service,
|
||||
get_version_service,
|
||||
get_track_download_service,
|
||||
)
|
||||
|
||||
|
||||
@@ -155,3 +157,4 @@ PlexPlaybackServiceDep = Annotated[PlexPlaybackService, Depends(get_plex_playbac
|
||||
CacheStatusServiceDep = Annotated[CacheStatusService, Depends(get_cache_status_service)]
|
||||
GitHubRepositoryDep = Annotated[GitHubRepository, Depends(get_github_repository)]
|
||||
VersionServiceDep = Annotated[VersionService, Depends(get_version_service)]
|
||||
TrackDownloadServiceDep = Annotated[TrackDownloadService, Depends(get_track_download_service)]
|
||||
|
||||
@@ -52,6 +52,8 @@ from api.v1.routes import plex_library as plex_library_routes
|
||||
from api.v1.routes import plex_auth as plex_auth_routes
|
||||
from api.v1.routes import version as version_routes
|
||||
from api.v1.routes import download as download_routes
|
||||
from api.v1.routes import track_download as track_download_routes
|
||||
from api.v1.routes import lidarr_request as lidarr_request_routes
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -289,6 +291,9 @@ app.add_middleware(
|
||||
"/api/v1/search": (10.0, 20),
|
||||
"/api/v1/discover": (10.0, 20),
|
||||
"/api/v1/covers": (15.0, 30),
|
||||
# No track-download override — middleware matches by prefix and would
|
||||
# drain the bucket via the polling GETs. The actual rate limit is the
|
||||
# worker's serial queue on gnat.
|
||||
},
|
||||
)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=6)
|
||||
@@ -344,6 +349,8 @@ v1_router.include_router(profile.router)
|
||||
v1_router.include_router(playlists.router)
|
||||
v1_router.include_router(version_routes.router)
|
||||
v1_router.include_router(download_routes.router)
|
||||
v1_router.include_router(track_download_routes.router)
|
||||
v1_router.include_router(lidarr_request_routes.router)
|
||||
app.include_router(v1_router)
|
||||
|
||||
mount_frontend(app)
|
||||
|
||||
@@ -315,6 +315,109 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
|
||||
logger.error(f"Failed to delete album {album_id}: {e}")
|
||||
raise
|
||||
|
||||
async def get_album_tracks_raw(self, album_id: int) -> list[dict[str, Any]]:
|
||||
"""Return Lidarr's raw /track response for an album.
|
||||
|
||||
Distinct from get_album_tracks (which projects to a simplified
|
||||
UI-friendly shape and drops foreignTrackId / foreignRecordingId / id).
|
||||
Callers that need MBIDs or the Lidarr internal track id (e.g., to
|
||||
flip track monitor state) want this method.
|
||||
|
||||
Returns tracks for the currently-monitored release. For the full
|
||||
cross-release set use get_album_tracks_raw_by_release per release.
|
||||
"""
|
||||
try:
|
||||
data = await self._get("/api/v1/track", params={"albumId": album_id})
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("get_album_tracks_raw failed for album %d: %s", album_id, e)
|
||||
return []
|
||||
|
||||
async def get_album_tracks_raw_by_release(
|
||||
self, album_release_id: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return raw tracks for a specific AlbumRelease.
|
||||
|
||||
Lidarr's /track endpoint accepts albumReleaseId as a filter; this
|
||||
is the only way to see tracks on a non-monitored release (the
|
||||
albumId filter only returns the active release's tracks).
|
||||
"""
|
||||
try:
|
||||
data = await self._get(
|
||||
"/api/v1/track", params={"albumReleaseId": album_release_id}
|
||||
)
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(
|
||||
"get_album_tracks_raw_by_release failed for release %d: %s",
|
||||
album_release_id, e,
|
||||
)
|
||||
return []
|
||||
|
||||
async def wait_for_album_tracks_raw(
|
||||
self, album_id: int, timeout_s: float = 30.0, poll_s: float = 1.0
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Poll get_album_tracks_raw until Lidarr has populated the track list.
|
||||
|
||||
Same shape as wait_for_album_tracks but returns the raw track payload
|
||||
so callers can access foreignTrackId / foreignRecordingId / id.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
tracks: list[dict[str, Any]] = []
|
||||
while time.monotonic() < deadline:
|
||||
tracks = await self.get_album_tracks_raw(album_id)
|
||||
if tracks:
|
||||
return tracks
|
||||
await asyncio.sleep(poll_s)
|
||||
return tracks
|
||||
|
||||
async def set_track_monitored(self, track_ids: list[int], monitored: bool) -> bool:
|
||||
"""PUT /track/monitor — fork-only endpoint on shaunrd0/Lidarr.
|
||||
|
||||
Stock Lidarr nightly returns 405 (endpoint doesn't exist). Callers
|
||||
are expected to ensure their LIDARR_URL points at a fork instance
|
||||
(currently lidarr-shared on gnat:8688).
|
||||
"""
|
||||
if not track_ids:
|
||||
return True
|
||||
try:
|
||||
await self._put(
|
||||
"/api/v1/track/monitor",
|
||||
{"trackIds": track_ids, "monitored": monitored},
|
||||
)
|
||||
return True
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("Failed to set track monitored for %d tracks: %s", len(track_ids), e)
|
||||
return False
|
||||
|
||||
async def wait_for_album_tracks(
|
||||
self, album_id: int, timeout_s: float = 30.0, poll_s: float = 1.0
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Poll get_album_tracks until Lidarr has populated the track list.
|
||||
|
||||
After an album add, Lidarr fetches track metadata from MusicBrainz
|
||||
asynchronously. The track list shows up within a few seconds for
|
||||
most albums; we poll with a short backoff so the caller doesn't
|
||||
need to busy-wait or guess.
|
||||
"""
|
||||
# Invalidate the in-process cache once so the first poll fetches fresh.
|
||||
# The cache is set with a 300s TTL inside get_album_tracks itself, and
|
||||
# without this an empty-on-first-call result would be cached and we'd
|
||||
# never see the real tracks until the TTL expired.
|
||||
cache_key = f"{LIDARR_ALBUM_TRACKS_PREFIX}{album_id}"
|
||||
await self._cache.delete(cache_key)
|
||||
|
||||
deadline = time.monotonic() + timeout_s
|
||||
tracks: list[dict[str, Any]] = []
|
||||
while time.monotonic() < deadline:
|
||||
tracks = await self.get_album_tracks(album_id)
|
||||
if tracks:
|
||||
return tracks
|
||||
# Same invalidation reason as above for subsequent polls.
|
||||
await self._cache.delete(cache_key)
|
||||
await asyncio.sleep(poll_s)
|
||||
return tracks
|
||||
|
||||
async def set_monitored(self, album_mbid: str, monitored: bool) -> bool:
|
||||
lidarr_album = await self._get_album_by_foreign_id(album_mbid)
|
||||
if not lidarr_album:
|
||||
@@ -329,7 +432,20 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
|
||||
await self._invalidate_album_list_caches()
|
||||
return True
|
||||
|
||||
async def add_album(self, musicbrainz_id: str, artist_repo) -> dict:
|
||||
async def add_album(
|
||||
self,
|
||||
musicbrainz_id: str,
|
||||
artist_repo,
|
||||
search_after_add: bool = True,
|
||||
) -> dict:
|
||||
"""Add an album to Lidarr.
|
||||
|
||||
search_after_add (default True): whether to fire AlbumSearch as part
|
||||
of the add. The single-track Lidarr request flow needs this False so
|
||||
it can unmonitor sibling tracks BEFORE Lidarr's auto-search races
|
||||
the import past the track-monitored gate. All other callers (UI add,
|
||||
playlist import, etc.) keep the existing default-true behavior.
|
||||
"""
|
||||
t0 = time.monotonic()
|
||||
if not musicbrainz_id or not isinstance(musicbrainz_id, str):
|
||||
raise ExternalServiceError("Invalid MBID provided")
|
||||
@@ -361,6 +477,7 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
|
||||
musicbrainz_id, artist_repo, t0,
|
||||
candidate, album_title, album_type, secondary_types,
|
||||
artist_mbid, artist_name,
|
||||
search_after_add=search_after_add,
|
||||
)
|
||||
|
||||
async def _add_album_locked(
|
||||
@@ -374,6 +491,7 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
|
||||
secondary_types: list,
|
||||
artist_mbid: str,
|
||||
artist_name: str | None,
|
||||
search_after_add: bool = True,
|
||||
) -> dict:
|
||||
# Capture which albums are already monitored so we can revert any Lidarr auto-monitors after the add
|
||||
pre_add_monitored_ids: set[int] = set()
|
||||
@@ -414,10 +532,11 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
|
||||
if not is_monitored:
|
||||
album_obj = await self._update_album(album_id, {"monitored": True})
|
||||
|
||||
try:
|
||||
await self._post_command({"name": "AlbumSearch", "albumIds": [album_id]})
|
||||
except ExternalServiceError:
|
||||
pass
|
||||
if search_after_add:
|
||||
try:
|
||||
await self._post_command({"name": "AlbumSearch", "albumIds": [album_id]})
|
||||
except ExternalServiceError:
|
||||
pass
|
||||
|
||||
await self._unmonitor_auto_monitored_albums(
|
||||
artist_id, musicbrainz_id, album_id, pre_add_monitored_ids
|
||||
@@ -474,7 +593,7 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
|
||||
"anyReleaseOk": True,
|
||||
"profileId": profile_id,
|
||||
"images": [],
|
||||
"addOptions": {"addType": "automatic", "searchForNewAlbum": True},
|
||||
"addOptions": {"addType": "automatic", "searchForNewAlbum": search_after_add},
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -487,12 +606,13 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
|
||||
if album_obj:
|
||||
if not album_obj.get("monitored"):
|
||||
album_obj = await self._update_album(album_obj["id"], {"monitored": True})
|
||||
try:
|
||||
await self._post_command(
|
||||
{"name": "AlbumSearch", "albumIds": [album_obj["id"]]}
|
||||
)
|
||||
except ExternalServiceError:
|
||||
pass
|
||||
if search_after_add:
|
||||
try:
|
||||
await self._post_command(
|
||||
{"name": "AlbumSearch", "albumIds": [album_obj["id"]]}
|
||||
)
|
||||
except ExternalServiceError:
|
||||
pass
|
||||
elif "post failed" in err_str or "405" in err_str or "metadata" in err_str:
|
||||
raise ExternalServiceError(
|
||||
f"Lidarr rejected '{album_title}' ({album_type}"
|
||||
@@ -519,10 +639,11 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
|
||||
set_artist_monitored=artist_created,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._post_command({"name": "AlbumSearch", "albumIds": [album_id]})
|
||||
except ExternalServiceError:
|
||||
pass
|
||||
if search_after_add:
|
||||
try:
|
||||
await self._post_command({"name": "AlbumSearch", "albumIds": [album_id]})
|
||||
except ExternalServiceError:
|
||||
pass
|
||||
|
||||
# Unmonitor albums that Lidarr auto-monitored during the add
|
||||
await self._unmonitor_auto_monitored_albums(
|
||||
|
||||
@@ -192,6 +192,37 @@ class LidarrArtistRepository(LidarrBase):
|
||||
except Exception as e: # noqa: BLE001
|
||||
return None
|
||||
|
||||
async def trigger_refresh_by_mbid(self, artist_mbid: str) -> int | None:
|
||||
"""Fire RefreshArtist for the artist matching `artist_mbid` (best effort,
|
||||
does NOT wait for completion). Returns the Lidarr command id on success.
|
||||
|
||||
Used by the track-download flow to scope a rescan to JUST the artist
|
||||
whose file was just written, rather than a full RescanFolders. Failures
|
||||
are swallowed — this is an enhancement, not a critical-path call.
|
||||
"""
|
||||
try:
|
||||
items = await self._get("/api/v1/artist", params={"mbId": artist_mbid})
|
||||
if not items or not isinstance(items, list):
|
||||
logger.debug("trigger_refresh_by_mbid: no Lidarr artist for mbid=%s", artist_mbid)
|
||||
return None
|
||||
artist_id = items[0].get("id")
|
||||
if not artist_id:
|
||||
return None
|
||||
cmd = await self._post_command(
|
||||
{"name": "RefreshArtist", "artistId": artist_id}
|
||||
)
|
||||
cmd_id = cmd.get("id") if isinstance(cmd, dict) else None
|
||||
logger.info(
|
||||
"trigger_refresh_by_mbid: fired RefreshArtist artist_id=%s mbid=%s cmd_id=%s",
|
||||
artist_id, artist_mbid, cmd_id,
|
||||
)
|
||||
return cmd_id
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(
|
||||
"trigger_refresh_by_mbid failed for mbid=%s: %s", artist_mbid, e
|
||||
)
|
||||
return None
|
||||
|
||||
async def delete_artist(self, artist_id: int, delete_files: bool = False) -> bool:
|
||||
try:
|
||||
params = {"deleteFiles": str(delete_files).lower(), "addImportListExclusion": "false"}
|
||||
|
||||
@@ -32,5 +32,7 @@ class LidarrRepository(
|
||||
super().__init__(settings, http_client, cache)
|
||||
self._request_history_store = request_history_store
|
||||
|
||||
async def add_album(self, musicbrainz_id: str) -> dict:
|
||||
return await LidarrAlbumRepository.add_album(self, musicbrainz_id, self)
|
||||
async def add_album(self, musicbrainz_id: str, search_after_add: bool = True) -> dict:
|
||||
return await LidarrAlbumRepository.add_album(
|
||||
self, musicbrainz_id, self, search_after_add=search_after_add
|
||||
)
|
||||
|
||||
@@ -702,7 +702,12 @@ class LibraryService:
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Jellyfin track resolution failed for %s", album_mbid, exc_info=True)
|
||||
|
||||
await self._memory_cache.set(cache_key, result, ttl_seconds=3600)
|
||||
# Short TTL — Lidarr's track-file IDs change on every rescan + import,
|
||||
# so a long cache holds stale IDs whose stream attempts then 404. With
|
||||
# the download-complete fan-out invalidating the cache on real changes
|
||||
# PLUS the stream endpoint self-healing on 404 (see stream.py), 60s
|
||||
# is enough to absorb burst traffic without holding stale data.
|
||||
await self._memory_cache.set(cache_key, result, ttl_seconds=60)
|
||||
return result
|
||||
|
||||
async def resolve_tracks_batch(
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
"""LidarrRequestService — orchestrates a single-track Lidarr request.
|
||||
|
||||
Fork-only addition, sibling to TrackDownloadService (which proxies to
|
||||
yt-dlp-worker). Where TrackDownloadService grabs a single audio file
|
||||
via yt-dlp, this service uses Lidarr's full indexer + download-client
|
||||
pipeline to grab the track from a release the user has configured
|
||||
indexers for (slskd, qBittorrent, NZBGet, etc.).
|
||||
|
||||
The flow only works end-to-end when LIDARR_URL points at an instance
|
||||
running the track-monitored fork (shaunrd0/Lidarr) — the PUT
|
||||
/track/monitor endpoint that this service relies on returns 405 on
|
||||
stock Lidarr. set_track_monitored() in the repo logs + returns False
|
||||
in that case so the request still completes (album gets added, search
|
||||
fires), but the user will see siblings on the album re-download too.
|
||||
|
||||
Race-condition handling: Lidarr's "add album" normally fires AlbumSearch
|
||||
immediately. That races our unmonitor — if the grab + import complete
|
||||
before we flip monitor flags, the fork's TrackMonitoredSpecification
|
||||
sees all-monitored tracks and accepts the full release. We pass
|
||||
search_after_add=False so Lidarr does NOT auto-search, then we set
|
||||
monitor flags across ALL releases (not just the currently-active one,
|
||||
since Lidarr's anyReleaseOk=true means the grab may match a release
|
||||
we didn't touch), then we trigger AlbumSearch ourselves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from api.v1.schemas.lidarr_request import (
|
||||
LidarrRequestAccepted,
|
||||
LidarrRequestStatusResponse,
|
||||
LidarrRequestTrackStatus,
|
||||
)
|
||||
from core.exceptions import (
|
||||
ExternalServiceError,
|
||||
ResourceNotFoundError,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from repositories.lidarr import LidarrRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Per-album-MBID asyncio locks. Rapid clicks on N tracks of the SAME album
|
||||
# serialize through the album's lock so each request sees the prior one's
|
||||
# monitor flips before deciding its own unmonitor strategy. Different
|
||||
# albums run in parallel as before.
|
||||
#
|
||||
# Bounded LRU to prevent unbounded growth — the existing per-artist lock
|
||||
# pattern in repositories/lidarr/album.py uses the same shape.
|
||||
_MAX_ALBUM_LOCKS = 64
|
||||
_album_locks: "collections.OrderedDict[str, asyncio.Lock]" = collections.OrderedDict()
|
||||
|
||||
|
||||
def _get_album_lock(album_mbid: str) -> asyncio.Lock:
|
||||
if album_mbid in _album_locks:
|
||||
_album_locks.move_to_end(album_mbid)
|
||||
return _album_locks[album_mbid]
|
||||
lock = asyncio.Lock()
|
||||
_album_locks[album_mbid] = lock
|
||||
# Best-effort eviction of an old unlocked entry when we exceed the cap.
|
||||
while len(_album_locks) > _MAX_ALBUM_LOCKS:
|
||||
for key in list(_album_locks.keys()):
|
||||
if not _album_locks[key].locked():
|
||||
del _album_locks[key]
|
||||
break
|
||||
else:
|
||||
break
|
||||
return lock
|
||||
|
||||
|
||||
class LidarrRequestService:
|
||||
"""Coordinate Lidarr add + selective monitor + search for a single track."""
|
||||
|
||||
def __init__(self, *, lidarr_repository: "LidarrRepository") -> None:
|
||||
self._lidarr = lidarr_repository
|
||||
|
||||
async def request_track(
|
||||
self,
|
||||
*,
|
||||
album_mbid: str,
|
||||
track_mbid: str,
|
||||
artist_mbid: str | None = None,
|
||||
track_position: int | None = None,
|
||||
disc_number: int | None = None,
|
||||
track_title: str | None = None,
|
||||
) -> LidarrRequestAccepted:
|
||||
# artist_mbid is optional — Lidarr's album lookup returns the artist
|
||||
# MBID server-side, so we only need album + track for the API flow.
|
||||
if not (album_mbid and track_mbid):
|
||||
raise ValidationError("album_mbid and track_mbid are required")
|
||||
|
||||
# Per-album serialization. Rapid clicks on 3 tracks from the same
|
||||
# album each acquire this lock in turn. Without it, all 3 requests
|
||||
# would race through get_album_by_mbid before any of them had a
|
||||
# chance to flip monitor flags, so all 3 would think they were the
|
||||
# "fresh add" and each would unmonitor every track that wasn't
|
||||
# their own target — leaving only the last-completed request's
|
||||
# track monitored.
|
||||
async with _get_album_lock(album_mbid):
|
||||
return await self._request_track_locked(
|
||||
album_mbid=album_mbid,
|
||||
track_mbid=track_mbid,
|
||||
track_position=track_position,
|
||||
disc_number=disc_number,
|
||||
track_title=track_title,
|
||||
)
|
||||
|
||||
async def get_status(self, album_mbid: str) -> LidarrRequestStatusResponse:
|
||||
"""Return per-track Lidarr-side status for an album.
|
||||
|
||||
Lets the UI render the LidarrRequestButton in its persistent state
|
||||
(idle / requested / downloaded) on page load instead of resetting
|
||||
to idle on every refresh. Cheap — one /album?foreignAlbumId= and
|
||||
one /track?albumId= per call. No mutations.
|
||||
|
||||
IMPORTANT: a track is reported as `monitored=true` ONLY when the
|
||||
full Lidarr chain agrees the user wants it — track + album + artist
|
||||
all monitored. Lidarr's search/grab query filters on all three, so
|
||||
a track with `track.Monitored=true` but `album.Monitored=false`
|
||||
will never actually get downloaded. Reporting just the track flag
|
||||
was misleading: an album auto-added through some other code path
|
||||
(recommendations, ListenBrainz, manual artist add) inherits the
|
||||
fork's Track.Monitored=true default on all its tracks, but with
|
||||
album/artist unmonitored Lidarr ignores them entirely — the UI
|
||||
would falsely render every track as "requested" even though the
|
||||
user never asked for them.
|
||||
|
||||
has_file is independent — if the file is on disk it's on disk
|
||||
regardless of monitor state.
|
||||
"""
|
||||
if not album_mbid:
|
||||
raise ValidationError("album_mbid is required")
|
||||
|
||||
album = await self._lidarr.get_album_by_mbid(album_mbid)
|
||||
if not album or not album.get("id"):
|
||||
return LidarrRequestStatusResponse(in_library=False, tracks=[])
|
||||
|
||||
album_monitored = bool(album.get("monitored", False))
|
||||
artist_monitored = bool((album.get("artist") or {}).get("monitored", False))
|
||||
chain_monitored = album_monitored and artist_monitored
|
||||
|
||||
# Only the active release's tracks matter for the UI — the user
|
||||
# only sees one release per album page anyway.
|
||||
tracks = await self._lidarr.get_album_tracks_raw(album["id"])
|
||||
out: list[LidarrRequestTrackStatus] = []
|
||||
for t in tracks:
|
||||
recording_mbid = t.get("foreignRecordingId") or ""
|
||||
try:
|
||||
position = int(t.get("trackNumber") or t.get("absoluteTrackNumber") or 0)
|
||||
except (TypeError, ValueError):
|
||||
position = 0
|
||||
try:
|
||||
disc_number = int(t.get("mediumNumber") or 1)
|
||||
except (TypeError, ValueError):
|
||||
disc_number = 1
|
||||
track_flag = bool(t.get("monitored", False))
|
||||
out.append(
|
||||
LidarrRequestTrackStatus(
|
||||
recording_mbid=recording_mbid,
|
||||
position=position,
|
||||
disc_number=disc_number,
|
||||
# AND the chain — see docstring above.
|
||||
monitored=track_flag and chain_monitored,
|
||||
has_file=bool(t.get("hasFile", False)),
|
||||
)
|
||||
)
|
||||
return LidarrRequestStatusResponse(in_library=True, tracks=out)
|
||||
|
||||
async def _request_track_locked(
|
||||
self,
|
||||
*,
|
||||
album_mbid: str,
|
||||
track_mbid: str,
|
||||
track_position: int | None,
|
||||
disc_number: int | None,
|
||||
track_title: str | None,
|
||||
) -> LidarrRequestAccepted:
|
||||
# 1. Ensure album exists in Lidarr WITHOUT triggering Lidarr's auto-
|
||||
# search-on-add (which would race our unmonitor below). add_album
|
||||
# returns a wrapper dict; we re-fetch by MBID for a consistent shape.
|
||||
#
|
||||
# `was_added_now` controls the unmonitor strategy below. On a fresh
|
||||
# add every track defaults to monitored=true, so we have to unmonitor
|
||||
# siblings or Lidarr will download the whole album. If the album
|
||||
# already existed, the user (or a prior request) has already set the
|
||||
# monitor state — we preserve it and just add OUR target to the
|
||||
# monitored set, so 3 rapid clicks on Tracks A/B/C end with all
|
||||
# three monitored, not just whichever click landed last.
|
||||
album = await self._lidarr.get_album_by_mbid(album_mbid)
|
||||
was_added_now = album is None
|
||||
if was_added_now:
|
||||
logger.info("lidarr-request: album %s not in Lidarr, adding (no auto-search)", album_mbid)
|
||||
try:
|
||||
await self._lidarr.add_album(album_mbid, search_after_add=False)
|
||||
except ExternalServiceError as e:
|
||||
raise ExternalServiceError(f"Lidarr add_album failed: {e}") from e
|
||||
album = await self._lidarr.get_album_by_mbid(album_mbid)
|
||||
|
||||
if not album or not album.get("id"):
|
||||
raise ExternalServiceError(
|
||||
f"Album {album_mbid} could not be added or found in Lidarr after add"
|
||||
)
|
||||
|
||||
album_id = album["id"]
|
||||
album_title = album.get("title", "Unknown")
|
||||
|
||||
# Ensure the artist is monitored. Lidarr's wanted/missing query (and
|
||||
# the scheduled MissingAlbumSearch) filter out tracks whose artist
|
||||
# is unmonitored, even if the tracks themselves are monitored — so
|
||||
# if a prior interaction unmonitored this artist, every track we
|
||||
# request would show as REQUESTED in our UI but Lidarr would never
|
||||
# actually search for it on its own cadence. (Our explicit
|
||||
# trigger_album_search below still fires once, but if that grab
|
||||
# doesn't land, no retry ever happens.)
|
||||
artist = album.get("artist") or {}
|
||||
artist_mbid_from_album = (
|
||||
artist.get("foreignArtistId") or artist.get("mbId")
|
||||
)
|
||||
if artist_mbid_from_album and not artist.get("monitored"):
|
||||
try:
|
||||
logger.info(
|
||||
"lidarr-request[album %d]: monitoring artist %s (was unmonitored)",
|
||||
album_id, artist_mbid_from_album,
|
||||
)
|
||||
await self._lidarr.update_artist_monitoring(
|
||||
artist_mbid_from_album, monitored=True, monitor_new_items="none",
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(
|
||||
"lidarr-request[album %d]: failed to monitor artist %s: %s",
|
||||
album_id, artist_mbid_from_album, e,
|
||||
)
|
||||
|
||||
# 2. Gather ALL tracks across every AlbumRelease. Lidarr's
|
||||
# anyReleaseOk=true means a grab can match any release of the
|
||||
# album, so we need to flip monitor flags on tracks in ALL
|
||||
# releases or the import for an unintended release sneaks past
|
||||
# our gate. The collector polls for releases since Lidarr
|
||||
# populates the releases array async after add.
|
||||
all_tracks = await self._collect_tracks_across_releases(album_id)
|
||||
if not all_tracks:
|
||||
raise ExternalServiceError(
|
||||
f"Lidarr did not populate tracks for album {album_id} within timeout"
|
||||
)
|
||||
|
||||
# 3. Identify the target track on the *currently-monitored* release
|
||||
# (for the response payload). For the unmonitor step we treat ALL
|
||||
# release-instances of this recording as "target."
|
||||
active_release_tracks = [
|
||||
t for t in all_tracks if t.get("_release_monitored")
|
||||
] or all_tracks
|
||||
target = _find_track(
|
||||
active_release_tracks, track_mbid, track_position, disc_number, track_title,
|
||||
)
|
||||
if not target:
|
||||
raise ResourceNotFoundError(
|
||||
f"Track {track_mbid} (pos={track_position}, disc={disc_number}, "
|
||||
f"title={track_title!r}) not found on Lidarr album {album_id} "
|
||||
f"({album_title})"
|
||||
)
|
||||
|
||||
target_id = target["id"]
|
||||
target_title = target.get("title", "Unknown")
|
||||
|
||||
# Match siblings vs targets across ALL releases using the same fuzzy
|
||||
# chain that resolved the active-release target above. Matching by
|
||||
# foreignRecordingId equality alone (the old behavior) misses songs
|
||||
# whose MB recording UUID differs across releases of the same album
|
||||
# — common for tracks released as singles with their own MB
|
||||
# recording entry distinct from the album recording. Hit us on
|
||||
# Gorillaz Demon Days 2026-05-29: the user's 7 requested tracks
|
||||
# were correctly monitored on the 23-track Bootleg (the active
|
||||
# release at request time), but only 5 carried over to the
|
||||
# 15-track Official that Lidarr later picked on import — Kids With
|
||||
# Guns and Feel Good Inc. each have a single-specific recording
|
||||
# UUID, so they silently dropped out of the monitored set when the
|
||||
# release switched. Re-running _find_track per release with the
|
||||
# user's original request data (track_mbid + position + disc +
|
||||
# title) catches them via the position+disc or title fallback even
|
||||
# when foreignRecordingId equality fails.
|
||||
tracks_by_release: dict[int | None, list[dict]] = collections.defaultdict(list)
|
||||
for t in all_tracks:
|
||||
tracks_by_release[t.get("_release_id")].append(t)
|
||||
|
||||
target_ids: set[int] = set()
|
||||
for rel_tracks in tracks_by_release.values():
|
||||
match = _find_track(
|
||||
rel_tracks, track_mbid, track_position, disc_number, track_title,
|
||||
)
|
||||
if match and match.get("id"):
|
||||
target_ids.add(match["id"])
|
||||
|
||||
# Defensive floor: if for some reason no cross-release match
|
||||
# resolved, at least monitor the active-release target we already
|
||||
# found. Shouldn't normally happen since _find_track succeeded on
|
||||
# the active release just above.
|
||||
if not target_ids:
|
||||
target_ids = {target_id}
|
||||
|
||||
sibling_ids = [
|
||||
t["id"] for t in all_tracks
|
||||
if t.get("id") and t["id"] not in target_ids
|
||||
]
|
||||
|
||||
# 4. Set monitor flags.
|
||||
# - was_added_now=True (fresh add, everything defaults monitored=true):
|
||||
# unmonitor siblings so they don't get downloaded.
|
||||
# - was_added_now=False (album was already in Lidarr): leave existing
|
||||
# monitor state alone — preserves any tracks the user (or prior
|
||||
# rapid clicks on this album) had set monitored. We just add OUR
|
||||
# target to the monitored set on top of whatever was there.
|
||||
unmonitored_ok = True
|
||||
unmonitored_count = 0
|
||||
if was_added_now and sibling_ids:
|
||||
unmonitored_ok = await self._lidarr.set_track_monitored(sibling_ids, monitored=False)
|
||||
unmonitored_count = len(sibling_ids) if unmonitored_ok else 0
|
||||
|
||||
if target_ids:
|
||||
await self._lidarr.set_track_monitored(list(target_ids), monitored=True)
|
||||
|
||||
# 5. NOW trigger AlbumSearch — flags are in place, fork's import
|
||||
# specification will reject siblings regardless of which release the
|
||||
# grab matches.
|
||||
command = await self._lidarr.trigger_album_search([album_id])
|
||||
command_id = command.get("id") if command else None
|
||||
|
||||
note = None
|
||||
if was_added_now and sibling_ids and not unmonitored_ok:
|
||||
note = (
|
||||
f"track unmonitor skipped (PUT /track/monitor returned non-OK; "
|
||||
f"Lidarr instance is likely not running the track-monitored fork). "
|
||||
f"Search will still fire but {len(sibling_ids)} sibling track(s) "
|
||||
f"on this album may also get downloaded."
|
||||
)
|
||||
logger.warning("lidarr-request[album %d]: %s", album_id, note)
|
||||
elif not was_added_now:
|
||||
note = (
|
||||
f"album already in Lidarr; preserved existing monitor state "
|
||||
f"and added '{target_title}' to the monitored set"
|
||||
)
|
||||
|
||||
return LidarrRequestAccepted(
|
||||
status="accepted",
|
||||
album_id=album_id,
|
||||
album_title=album_title,
|
||||
track_id=target_id,
|
||||
track_title=target_title,
|
||||
other_tracks_unmonitored=unmonitored_count,
|
||||
command_id=command_id,
|
||||
note=note,
|
||||
)
|
||||
|
||||
async def _collect_tracks_across_releases(self, album_id: int) -> list[dict]:
|
||||
"""Fetch raw tracks for every AlbumRelease of an album.
|
||||
|
||||
Returns a flat list of track dicts, each annotated with
|
||||
``_release_monitored: bool`` so the caller can identify which
|
||||
tracks belong to the currently-active release.
|
||||
|
||||
Polls /album/{id} until the `releases` array is populated —
|
||||
Lidarr fetches it async after add and a too-early read returns
|
||||
an empty list. Then iterates every release, including the active
|
||||
one, fetching raw tracks per-release via /track?albumReleaseId=.
|
||||
Using the per-release filter (rather than albumId) avoids
|
||||
depending on which release Lidarr currently flags as active.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# Poll for releases — Lidarr populates them async after add.
|
||||
deadline = __import__("time").monotonic() + 30.0
|
||||
album_full = None
|
||||
releases: list[dict] = []
|
||||
while __import__("time").monotonic() < deadline:
|
||||
album_full = await self._lidarr.get_album_by_id(album_id)
|
||||
releases = (album_full or {}).get("releases") or []
|
||||
if releases:
|
||||
break
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
if not releases:
|
||||
# Fallback: at least try the active-release-only fetch so the
|
||||
# caller still has something to identify the target against.
|
||||
logger.warning(
|
||||
"lidarr-request[album %d]: no releases populated within timeout; "
|
||||
"falling back to active-release-only track fetch",
|
||||
album_id,
|
||||
)
|
||||
active_only = await self._lidarr.wait_for_album_tracks_raw(
|
||||
album_id, timeout_s=30.0
|
||||
)
|
||||
# _release_id=None in fallback — caller groups tracks by it,
|
||||
# so a single None bucket Just Works.
|
||||
return [
|
||||
{**t, "_release_monitored": True, "_release_id": None}
|
||||
for t in active_only
|
||||
]
|
||||
|
||||
out: list[dict] = []
|
||||
for release in releases:
|
||||
release_id = release.get("id")
|
||||
if not release_id:
|
||||
continue
|
||||
release_tracks = await self._lidarr.get_album_tracks_raw_by_release(release_id)
|
||||
release_monitored = bool(release.get("monitored"))
|
||||
# Annotate _release_id so callers can group tracks by their
|
||||
# owning release for per-release matching (see target_ids
|
||||
# computation in _request_track_locked).
|
||||
out.extend(
|
||||
{
|
||||
**t,
|
||||
"_release_monitored": release_monitored,
|
||||
"_release_id": release_id,
|
||||
}
|
||||
for t in release_tracks
|
||||
)
|
||||
|
||||
if not out:
|
||||
logger.warning(
|
||||
"lidarr-request[album %d]: all %d releases returned 0 tracks",
|
||||
album_id, len(releases),
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _find_track(
|
||||
tracks: list[dict],
|
||||
track_mbid: str,
|
||||
track_position: int | None,
|
||||
disc_number: int | None,
|
||||
track_title: str | None,
|
||||
) -> dict | None:
|
||||
"""Resolve a target track within Lidarr's track list for an album.
|
||||
|
||||
Lidarr's foreignRecordingId doesn't always equal MusicBrainz's
|
||||
recording_id (Lidarr sometimes stores a release-specific variant).
|
||||
Popular Songs lists from Last.fm also don't always have position/disc
|
||||
populated. So we try four strategies in priority order; the first
|
||||
unambiguous match wins.
|
||||
|
||||
Match priority:
|
||||
1. foreignTrackId == track_mbid (the MB track id, when sent)
|
||||
2. foreignRecordingId == track_mbid (the MB recording id)
|
||||
3. position + disc fallback (when both provided and exactly one match)
|
||||
4. title fallback — exact-then-substring, case-insensitive
|
||||
"""
|
||||
for t in tracks:
|
||||
if t.get("foreignTrackId") == track_mbid:
|
||||
return t
|
||||
for t in tracks:
|
||||
if t.get("foreignRecordingId") == track_mbid:
|
||||
return t
|
||||
if track_position is not None and disc_number is not None:
|
||||
matches = [
|
||||
t for t in tracks
|
||||
if t.get("mediumNumber") == disc_number
|
||||
and str(t.get("trackNumber", "")) == str(track_position)
|
||||
]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if track_title:
|
||||
needle = track_title.strip().lower()
|
||||
# Exact case-insensitive first.
|
||||
exact = [t for t in tracks if (t.get("title") or "").strip().lower() == needle]
|
||||
if len(exact) == 1:
|
||||
return exact[0]
|
||||
# Then substring (handles "Drive My Car" vs "Drive My Car (Remastered 2009)"
|
||||
# and similar reissue annotations). Only commit if exactly one match.
|
||||
substr = [
|
||||
t for t in tracks
|
||||
if needle in (t.get("title") or "").strip().lower()
|
||||
or (t.get("title") or "").strip().lower() in needle
|
||||
]
|
||||
if len(substr) == 1:
|
||||
return substr[0]
|
||||
return None
|
||||
@@ -0,0 +1,323 @@
|
||||
"""TrackDownloadService — proxies inline track-download requests to the
|
||||
yt-dlp-worker sidecar (on gnat). Library label is stamped from env config
|
||||
so the public musicseerr instance cannot reach Music-Personal.
|
||||
|
||||
Also fires a scoped Lidarr RefreshArtist the FIRST TIME a job status flips
|
||||
to "done" — so the new file gets picked up immediately instead of waiting
|
||||
for Lidarr's next scheduled scan (which could be hours). Without this, the
|
||||
file lands on disk but Lidarr's DB doesn't know about it, and the resolve
|
||||
endpoint may either miss it (track not playable) or worse — return a stale
|
||||
track_file_id whose path no longer exists (we hit this 2026-05-25 after a
|
||||
drive swap).
|
||||
|
||||
Fork-only addition; do not entangle with services/youtube_service.py
|
||||
(which uses the YouTube Data API and is subject to upstream rebase).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
from api.v1.schemas.track_download import (
|
||||
SearchSource,
|
||||
TrackDownloadAccepted,
|
||||
TrackDownloadCandidate,
|
||||
TrackDownloadJobStatus,
|
||||
TrackDownloadSearchResponse,
|
||||
)
|
||||
from core.exceptions import ExternalServiceError, ResourceNotFoundError, ValidationError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from repositories.lidarr import LidarrRepository
|
||||
from infrastructure.cache.memory_cache import CacheInterface
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
VALID_LIBRARIES = frozenset({"music", "music-personal", "music-shared"})
|
||||
|
||||
# Cache prefixes invalidated on download completion. Mirrors constants in
|
||||
# infrastructure/cache/cache_keys.py (kept as string literals here to avoid a
|
||||
# circular import at this layer).
|
||||
#
|
||||
# Need ALL of these — clearing only source_resolution leaves upstream Lidarr
|
||||
# album/track caches (5 min TTL) stale. Resolve re-fetches but then calls
|
||||
# get_album_details which still returns hasFile=false from the Lidarr cache,
|
||||
# so the new track keeps showing "not in library" until those TTLs expire.
|
||||
_DOWNLOAD_COMPLETE_CACHE_PREFIXES = (
|
||||
"source_resolution",
|
||||
"lidarr_album_details:",
|
||||
"lidarr_album_tracks:",
|
||||
"lidarr_album_trackfiles_raw:",
|
||||
"lidarr_artist_albums:",
|
||||
"lidarr_artist_details:",
|
||||
)
|
||||
|
||||
|
||||
class TrackDownloadService:
|
||||
"""Thin async proxy to the yt-dlp-worker. The library label is fixed
|
||||
at construction time and applied to every download request.
|
||||
|
||||
On first-seen status=done for any job, fans out three best-effort
|
||||
actions so the new file is immediately discoverable end-to-end:
|
||||
1. Lidarr RefreshArtist (per-artist DB refresh + disk scan)
|
||||
2. Plex library/sections/<id>/refresh (so Plex indexes the file)
|
||||
3. memory_cache.clear_prefix(source_resolution) (so musicseerr's
|
||||
resolve endpoint doesn't return stale "not in library" data
|
||||
from before the file landed)
|
||||
|
||||
Any single piece can fail without breaking the others — download
|
||||
success is reported regardless of post-download fan-out outcomes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
worker_url: str,
|
||||
library: str,
|
||||
lidarr_repository: "LidarrRepository | None" = None,
|
||||
memory_cache: "CacheInterface | None" = None,
|
||||
plex_url: str = "",
|
||||
plex_token: str = "",
|
||||
plex_section_id: int = 0,
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
if library not in VALID_LIBRARIES:
|
||||
raise ValueError(
|
||||
f"MUSICSEERR_LIBRARY must be one of {sorted(VALID_LIBRARIES)}; got '{library}'"
|
||||
)
|
||||
self._worker_url = worker_url.rstrip("/")
|
||||
self._library = library
|
||||
self._timeout = timeout
|
||||
self._lidarr = lidarr_repository
|
||||
self._memory_cache = memory_cache
|
||||
self._plex_url = plex_url.rstrip("/")
|
||||
self._plex_token = plex_token
|
||||
self._plex_section_id = plex_section_id
|
||||
# job_id → artist_mbid, captured at request time so we can fire a
|
||||
# scoped RefreshArtist when the poll later sees status="done".
|
||||
# In-memory only — if musicseerr restarts mid-download the rescan
|
||||
# for that job is just skipped (best-effort enhancement).
|
||||
self._mbid_by_job: dict[str, str] = {}
|
||||
# job_ids we've already fired RefreshArtist for; prevents the same
|
||||
# rescan firing every poll after completion (frontend keeps polling
|
||||
# for a few cycles after status=done to confirm the final state).
|
||||
self._rescan_fired: set[str] = set()
|
||||
|
||||
@property
|
||||
def library(self) -> str:
|
||||
return self._library
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
source: SearchSource = "youtube",
|
||||
) -> TrackDownloadSearchResponse:
|
||||
payload = {"query": query, "limit": limit, "source": source}
|
||||
data = await self._post_json("/search", payload)
|
||||
candidates = [
|
||||
TrackDownloadCandidate(
|
||||
video_id=c["video_id"],
|
||||
url=c["url"],
|
||||
title=c["title"],
|
||||
source=c.get("source", "youtube"),
|
||||
channel=c.get("channel"),
|
||||
artist=c.get("artist"),
|
||||
album=c.get("album"),
|
||||
duration_seconds=c.get("duration_seconds"),
|
||||
thumbnail_url=c.get("thumbnail_url"),
|
||||
)
|
||||
for c in data.get("candidates", [])
|
||||
]
|
||||
return TrackDownloadSearchResponse(candidates=candidates)
|
||||
|
||||
async def request_download(
|
||||
self,
|
||||
*,
|
||||
video_id: str,
|
||||
artist: str,
|
||||
album: str,
|
||||
track_title: str,
|
||||
source: SearchSource = "youtube",
|
||||
target_duration_seconds: int | None = None,
|
||||
artist_mbid: str | None,
|
||||
track_position: int | None,
|
||||
disc_number: int | None,
|
||||
user_id: str | None = None,
|
||||
) -> TrackDownloadAccepted:
|
||||
payload: dict[str, Any] = {
|
||||
"video_id": video_id,
|
||||
"source": source,
|
||||
"target_duration_seconds": target_duration_seconds,
|
||||
"artist": artist,
|
||||
"album": album,
|
||||
"track_title": track_title,
|
||||
"artist_mbid": artist_mbid,
|
||||
"track_position": track_position,
|
||||
"disc_number": disc_number,
|
||||
"library": self._library,
|
||||
"user_id": user_id,
|
||||
}
|
||||
data = await self._post_json("/download", payload, expected_status={200, 202})
|
||||
job_id = data["job_id"]
|
||||
# Stash the mbid so we can fire a scoped Lidarr rescan on completion.
|
||||
# mbid is optional — only useful if we have a real MusicBrainz id.
|
||||
if artist_mbid:
|
||||
self._mbid_by_job[job_id] = artist_mbid
|
||||
return TrackDownloadAccepted(job_id=job_id)
|
||||
|
||||
async def get_job(self, job_id: str) -> TrackDownloadJobStatus:
|
||||
data = await self._get_json(f"/jobs/{job_id}")
|
||||
status = data["status"]
|
||||
|
||||
# First-seen transition to "done" → fan out three best-effort actions
|
||||
# asynchronously so the new file is discoverable end-to-end without
|
||||
# waiting on any one of them. Each is fire-and-forget; failures in
|
||||
# one don't block the others or affect the download status returned
|
||||
# to the client.
|
||||
if status == "done" and job_id not in self._rescan_fired:
|
||||
self._rescan_fired.add(job_id)
|
||||
mbid = self._mbid_by_job.pop(job_id, None)
|
||||
asyncio.create_task(self._on_download_complete(job_id, mbid))
|
||||
|
||||
return TrackDownloadJobStatus(
|
||||
id=data["id"],
|
||||
status=status,
|
||||
artist=data["artist"],
|
||||
album=data["album"],
|
||||
track_title=data["track_title"],
|
||||
library=data["library"],
|
||||
file_path=data.get("file_path"),
|
||||
error=data.get("error"),
|
||||
created_at=data["created_at"],
|
||||
updated_at=data["updated_at"],
|
||||
)
|
||||
|
||||
async def _on_download_complete(self, job_id: str, mbid: str | None) -> None:
|
||||
"""Three-step post-download fan-out. Best-effort; any single failure
|
||||
is logged but doesn't propagate (the download itself already succeeded).
|
||||
"""
|
||||
# 1. Lidarr per-artist refresh (so Lidarr DB sees the file)
|
||||
if mbid and self._lidarr is not None:
|
||||
try:
|
||||
logger.info(
|
||||
"on_download_complete[%s]: fire Lidarr RefreshArtist mbid=%s",
|
||||
job_id, mbid,
|
||||
)
|
||||
await self._lidarr.trigger_refresh_by_mbid(mbid)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(
|
||||
"on_download_complete[%s]: Lidarr refresh failed: %s", job_id, e
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"on_download_complete[%s]: Lidarr refresh skipped (mbid=%s lidarr=%s)",
|
||||
job_id, bool(mbid), bool(self._lidarr),
|
||||
)
|
||||
|
||||
# 2. Plex section refresh (so Plex indexes the new file → Plexamp can play it)
|
||||
if self._plex_url and self._plex_token and self._plex_section_id:
|
||||
try:
|
||||
logger.info(
|
||||
"on_download_complete[%s]: fire Plex refresh section=%d",
|
||||
job_id, self._plex_section_id,
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
r = await client.get(
|
||||
f"{self._plex_url}/library/sections/{self._plex_section_id}/refresh",
|
||||
headers={"X-Plex-Token": self._plex_token},
|
||||
)
|
||||
if r.status_code >= 300:
|
||||
logger.warning(
|
||||
"on_download_complete[%s]: Plex refresh returned HTTP %d",
|
||||
job_id, r.status_code,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(
|
||||
"on_download_complete[%s]: Plex refresh failed: %s", job_id, e
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"on_download_complete[%s]: Plex refresh skipped (not configured)",
|
||||
job_id,
|
||||
)
|
||||
|
||||
# 3. Invalidate musicseerr's caches — clears stale "track not in library"
|
||||
# answers AND the upstream Lidarr album/track caches that would otherwise
|
||||
# be queried after the source_resolution miss and just return stale data
|
||||
# for another 5 minutes. Over-eager: clears the entire prefix per layer,
|
||||
# not just this artist's album. Safe — caches rehydrate on demand.
|
||||
if self._memory_cache is not None:
|
||||
total = 0
|
||||
for prefix in _DOWNLOAD_COMPLETE_CACHE_PREFIXES:
|
||||
try:
|
||||
n = await self._memory_cache.clear_prefix(prefix)
|
||||
total += n
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(
|
||||
"on_download_complete[%s]: cache invalidate failed for prefix %s: %s",
|
||||
job_id, prefix, e,
|
||||
)
|
||||
logger.info(
|
||||
"on_download_complete[%s]: cleared %d cache entries across %d prefixes",
|
||||
job_id, total, len(_DOWNLOAD_COMPLETE_CACHE_PREFIXES),
|
||||
)
|
||||
|
||||
# ---- internal HTTP helpers ----
|
||||
|
||||
async def _post_json(
|
||||
self,
|
||||
path: str,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
expected_status: set[int] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
url = f"{self._worker_url}{path}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
r = await client.post(url, json=payload)
|
||||
except httpx.HTTPError as e:
|
||||
logger.error("yt-dlp-worker POST %s failed: %s", path, e)
|
||||
raise ExternalServiceError(
|
||||
f"yt-dlp-worker unreachable: {e}"
|
||||
) from e
|
||||
return self._handle_response(r, path, expected_status)
|
||||
|
||||
async def _get_json(self, path: str) -> dict[str, Any]:
|
||||
url = f"{self._worker_url}{path}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
r = await client.get(url)
|
||||
except httpx.HTTPError as e:
|
||||
logger.error("yt-dlp-worker GET %s failed: %s", path, e)
|
||||
raise ExternalServiceError(
|
||||
f"yt-dlp-worker unreachable: {e}"
|
||||
) from e
|
||||
return self._handle_response(r, path)
|
||||
|
||||
@staticmethod
|
||||
def _handle_response(
|
||||
r: httpx.Response,
|
||||
path: str,
|
||||
expected_status: set[int] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if r.status_code == 404:
|
||||
raise ResourceNotFoundError(f"yt-dlp-worker {path}: not found")
|
||||
if r.status_code == 422:
|
||||
raise ValidationError(f"yt-dlp-worker {path}: invalid request: {r.text[:200]}")
|
||||
if r.status_code == 429:
|
||||
raise ValidationError("Rate limit exceeded; try again shortly")
|
||||
if expected_status is not None and r.status_code not in expected_status:
|
||||
raise ExternalServiceError(
|
||||
f"yt-dlp-worker {path}: unexpected status {r.status_code}: {r.text[:200]}"
|
||||
)
|
||||
if expected_status is None and (r.status_code < 200 or r.status_code >= 300):
|
||||
raise ExternalServiceError(
|
||||
f"yt-dlp-worker {path}: unexpected status {r.status_code}: {r.text[:200]}"
|
||||
)
|
||||
return r.json()
|
||||
@@ -0,0 +1,16 @@
|
||||
from api.v1.schemas.settings import HomeSettings
|
||||
|
||||
|
||||
class TestHomeSettingsDefaults:
|
||||
def test_show_now_playing_defaults_off(self) -> None:
|
||||
# Default off: Plex /status/sessions returns sessions across the whole
|
||||
# server with no library-section filter, so a shared instance leaks
|
||||
# every household member's listening activity. The privacy default
|
||||
# must be off — opt-in per instance.
|
||||
assert HomeSettings().show_now_playing is False
|
||||
|
||||
def test_show_whats_hot_default_unchanged(self) -> None:
|
||||
assert HomeSettings().show_whats_hot is True
|
||||
|
||||
def test_show_globally_trending_default_unchanged(self) -> None:
|
||||
assert HomeSettings().show_globally_trending is True
|
||||
@@ -0,0 +1,203 @@
|
||||
"""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]
|
||||
@@ -9,7 +9,11 @@ from core.dependencies._registry import _singleton_registry, clear_all_singleton
|
||||
|
||||
class TestSingletonRegistry:
|
||||
def test_registry_has_expected_count(self):
|
||||
assert len(_singleton_registry) == 57
|
||||
# Bumped from 57 to 59 for the fork-added singletons:
|
||||
# get_track_download_service (yt-dlp proxy)
|
||||
# get_lidarr_request_service (single-track Lidarr request flow)
|
||||
# Update this number whenever you add/remove an @singleton.
|
||||
assert len(_singleton_registry) == 59
|
||||
|
||||
def test_all_entries_have_cache_clear(self):
|
||||
for fn in _singleton_registry:
|
||||
|
||||
@@ -15,7 +15,7 @@ def _make_local_files_service(lidarr=None, cache=None):
|
||||
cache_ttl_local_files_storage_stats=300,
|
||||
)
|
||||
prefs.get_local_files_connection.return_value = MagicMock(
|
||||
music_path="/music", lidarr_root_path="/music"
|
||||
music_path="/music", lidarr_root_path="/data"
|
||||
)
|
||||
cache = cache or AsyncMock()
|
||||
return LocalFilesService(
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { api } from '$lib/api/client';
|
||||
|
||||
export interface LidarrRequestPayload {
|
||||
album_mbid: string;
|
||||
track_mbid: string;
|
||||
artist_mbid?: string | null;
|
||||
track_position?: number | null;
|
||||
disc_number?: number | null;
|
||||
track_title?: string | null;
|
||||
}
|
||||
|
||||
export interface LidarrRequestAccepted {
|
||||
status: string;
|
||||
album_id: number;
|
||||
album_title: string;
|
||||
track_id: number;
|
||||
track_title: string;
|
||||
other_tracks_unmonitored: number;
|
||||
command_id: number | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface LidarrRequestTrackStatus {
|
||||
recording_mbid: string;
|
||||
position: number;
|
||||
disc_number: number;
|
||||
monitored: boolean;
|
||||
has_file: boolean;
|
||||
}
|
||||
|
||||
export interface LidarrRequestStatusResponse {
|
||||
in_library: boolean;
|
||||
tracks: LidarrRequestTrackStatus[];
|
||||
}
|
||||
|
||||
/** The 3 persistent UI states the LidarrRequestButton can render. */
|
||||
export type LidarrButtonStatus = 'none' | 'requested' | 'downloaded';
|
||||
|
||||
/** Project a single Lidarr per-track entry into a UI-friendly status. */
|
||||
export function projectButtonStatus(
|
||||
t: LidarrRequestTrackStatus | undefined | null
|
||||
): LidarrButtonStatus {
|
||||
if (!t) return 'none';
|
||||
if (t.has_file) return 'downloaded';
|
||||
if (t.monitored) return 'requested';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a status lookup over a status response.
|
||||
*
|
||||
* Indexes by both recording_mbid AND `albumMbid:position:disc` because
|
||||
* Lidarr's foreignRecordingId doesn't always equal MusicBrainz's
|
||||
* recording_id (Lidarr sometimes maps to a variant). The album_mbid is
|
||||
* baked into the position key as a safety belt so a stale lookup from
|
||||
* a previous album page can't false-positive-match the visible album's
|
||||
* tracks at the same positions (positions 1, 2, 3… collide trivially
|
||||
* across every album in existence otherwise).
|
||||
*/
|
||||
export function buildStatusLookup(albumMbid: string, res: LidarrRequestStatusResponse) {
|
||||
const byMbid = new Map<string, LidarrRequestTrackStatus>();
|
||||
const byPositionDisc = new Map<string, LidarrRequestTrackStatus>();
|
||||
for (const t of res.tracks) {
|
||||
if (t.recording_mbid) byMbid.set(t.recording_mbid, t);
|
||||
byPositionDisc.set(`${albumMbid}:${t.position}:${t.disc_number}`, t);
|
||||
}
|
||||
return {
|
||||
albumMbid,
|
||||
byMbid,
|
||||
byPositionDisc,
|
||||
lookup(
|
||||
lookupAlbumMbid: string,
|
||||
recordingMbid: string | null | undefined,
|
||||
position: number,
|
||||
discNumber: number
|
||||
) {
|
||||
if (recordingMbid && byMbid.has(recordingMbid)) return byMbid.get(recordingMbid);
|
||||
// Album-scope the position fallback — won't match tracks from a
|
||||
// different album even if the caller is somehow using a stale
|
||||
// lookup built for that other album.
|
||||
if (lookupAlbumMbid !== albumMbid) return undefined;
|
||||
return byPositionDisc.get(`${albumMbid}:${position}:${discNumber}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const ROOT = '/api/v1/lidarr-request';
|
||||
|
||||
export async function requestTrackViaLidarr(
|
||||
payload: LidarrRequestPayload,
|
||||
signal?: AbortSignal
|
||||
): Promise<LidarrRequestAccepted> {
|
||||
return api.global.post<LidarrRequestAccepted>(ROOT, payload, { signal });
|
||||
}
|
||||
|
||||
export async function getLidarrRequestStatus(
|
||||
albumMbid: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<LidarrRequestStatusResponse> {
|
||||
return api.global.get<LidarrRequestStatusResponse>(
|
||||
`${ROOT}/status?album_mbid=${encodeURIComponent(albumMbid)}`,
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { api } from '$lib/api/client';
|
||||
|
||||
export type TrackDownloadStatus =
|
||||
| 'queued'
|
||||
| 'searching'
|
||||
| 'downloading'
|
||||
| 'tagging'
|
||||
| 'importing'
|
||||
| 'done'
|
||||
| 'failed';
|
||||
|
||||
export type TrackDownloadSource = 'youtube' | 'spotify';
|
||||
|
||||
export const TRACK_DOWNLOAD_TERMINAL_STATES: ReadonlySet<TrackDownloadStatus> = new Set([
|
||||
'done',
|
||||
'failed'
|
||||
]);
|
||||
|
||||
export interface TrackDownloadCandidate {
|
||||
video_id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
source: TrackDownloadSource;
|
||||
channel: string | null;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
duration_seconds: number | null;
|
||||
thumbnail_url: string | null;
|
||||
}
|
||||
|
||||
export interface TrackDownloadSearchResponse {
|
||||
candidates: TrackDownloadCandidate[];
|
||||
}
|
||||
|
||||
export interface TrackDownloadRequestPayload {
|
||||
video_id: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
track_title: string;
|
||||
source?: TrackDownloadSource;
|
||||
target_duration_seconds?: number | null;
|
||||
artist_mbid?: string | null;
|
||||
track_position?: number | null;
|
||||
disc_number?: number | null;
|
||||
}
|
||||
|
||||
export interface TrackDownloadAccepted {
|
||||
job_id: string;
|
||||
}
|
||||
|
||||
export interface TrackDownloadJobStatus {
|
||||
id: string;
|
||||
status: TrackDownloadStatus;
|
||||
artist: string;
|
||||
album: string;
|
||||
track_title: string;
|
||||
library: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
file_path: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const ROOT = '/api/v1/track-download';
|
||||
|
||||
export async function searchTrackCandidates(
|
||||
query: string,
|
||||
limit = 5,
|
||||
source: TrackDownloadSource = 'youtube',
|
||||
signal?: AbortSignal
|
||||
): Promise<TrackDownloadSearchResponse> {
|
||||
return api.global.post<TrackDownloadSearchResponse>(
|
||||
`${ROOT}/search`,
|
||||
{ query, limit, source },
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
export async function requestTrackDownload(
|
||||
payload: TrackDownloadRequestPayload,
|
||||
signal?: AbortSignal
|
||||
): Promise<TrackDownloadAccepted> {
|
||||
return api.global.post<TrackDownloadAccepted>(ROOT, payload, { signal });
|
||||
}
|
||||
|
||||
export async function getTrackDownloadJob(
|
||||
jobId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<TrackDownloadJobStatus> {
|
||||
return api.global.get<TrackDownloadJobStatus>(`${ROOT}/${jobId}`, { signal });
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { Library, Check, AlertTriangle, Loader2, Hourglass } from 'lucide-svelte';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import { requestTrackViaLidarr, type LidarrButtonStatus } from '$lib/api/lidarrRequest';
|
||||
import { colors } from '$lib/colors';
|
||||
|
||||
// LidarrButtonStatus comes from the API client module. Hydrating via
|
||||
// `initialStatus` lets the page render `requested` / `downloaded` even
|
||||
// after a refresh — without it, the button only knows transient session
|
||||
// state and forgets every prior click.
|
||||
|
||||
interface Props {
|
||||
albumMbid: string;
|
||||
trackMbid: string;
|
||||
trackTitle: string;
|
||||
artistMbid?: string | null;
|
||||
trackPosition?: number | null;
|
||||
discNumber?: number | null;
|
||||
size?: 'sm' | 'md';
|
||||
initialStatus?: LidarrButtonStatus;
|
||||
}
|
||||
|
||||
let {
|
||||
albumMbid,
|
||||
trackMbid,
|
||||
trackTitle,
|
||||
artistMbid = null,
|
||||
trackPosition = null,
|
||||
discNumber = null,
|
||||
size = 'sm',
|
||||
initialStatus = 'none'
|
||||
}: Props = $props();
|
||||
|
||||
type ButtonState = 'idle' | 'submitting' | 'failed' | 'requested' | 'downloaded';
|
||||
|
||||
// `state` is what we actually render. Hydrate from initialStatus on
|
||||
// mount, then a successful click flips us to `requested` immediately
|
||||
// (optimistic — Lidarr just confirmed it accepted the search).
|
||||
let state = $state<ButtonState>(initialStatusToState(initialStatus));
|
||||
let errorMsg = $state<string | null>(null);
|
||||
let successNote = $state<string | null>(null);
|
||||
|
||||
// React to upstream initialStatus changes (e.g., page refresh re-fetched
|
||||
// the parent's status map and a track that was idle now shows as
|
||||
// downloaded because a background grab finished).
|
||||
//
|
||||
// IMPORTANT: read `state` via `untrack` so this effect only depends on
|
||||
// `initialStatus`. Without it, the optimistic flip in handleClick
|
||||
// (state = 'requested') re-fires this effect, and because the parent
|
||||
// hasn't re-polled yet `initialStatus` is still 'none', so state gets
|
||||
// reset to 'idle' and the user sees the button revert. Hit 2026-05-29
|
||||
// on Led Zeppelin — button briefly showed Hourglass then snapped back
|
||||
// to Library until a page refresh.
|
||||
//
|
||||
// Additionally, only ADOPT the parent's view if it's at least as
|
||||
// strong as our local state: idle (0) < requested (1) < downloaded (2).
|
||||
// This prevents the parent's late-arriving 'none' from downgrading
|
||||
// our just-flipped 'requested'. Upgrades (requested → downloaded)
|
||||
// still flow through normally.
|
||||
const STATE_RANK: Record<ButtonState, number> = {
|
||||
idle: 0,
|
||||
submitting: 0,
|
||||
failed: 0,
|
||||
requested: 1,
|
||||
downloaded: 2
|
||||
};
|
||||
$effect(() => {
|
||||
const incoming = initialStatusToState(initialStatus);
|
||||
untrack(() => {
|
||||
if (state === 'submitting' || state === 'failed') return;
|
||||
if (STATE_RANK[incoming] >= STATE_RANK[state]) {
|
||||
state = incoming;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function initialStatusToState(s: LidarrButtonStatus): ButtonState {
|
||||
switch (s) {
|
||||
case 'downloaded':
|
||||
return 'downloaded';
|
||||
case 'requested':
|
||||
return 'requested';
|
||||
default:
|
||||
return 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
const isReady = $derived(!!albumMbid && !!trackMbid);
|
||||
|
||||
async function handleClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!isReady) return;
|
||||
// Don't fire if we already know Lidarr has it (monitored or on disk).
|
||||
// Click stays a no-op visually — the persistent icon is enough signal.
|
||||
if (state === 'requested' || state === 'downloaded' || state === 'submitting') return;
|
||||
state = 'submitting';
|
||||
errorMsg = null;
|
||||
successNote = null;
|
||||
try {
|
||||
const res = await requestTrackViaLidarr({
|
||||
album_mbid: albumMbid,
|
||||
track_mbid: trackMbid,
|
||||
artist_mbid: artistMbid,
|
||||
track_position: trackPosition,
|
||||
disc_number: discNumber,
|
||||
// Title is the last-ditch fallback for the backend matcher.
|
||||
// Lidarr's foreignRecordingId doesn't always equal MB's
|
||||
// recording_id, and Popular Songs lists often lack track
|
||||
// position/disc — title gets us through both cases.
|
||||
track_title: trackTitle
|
||||
});
|
||||
successNote = res.note;
|
||||
// Optimistic flip: Lidarr accepted, treat as requested. If a
|
||||
// later parent refetch flips us to `downloaded`, the $effect
|
||||
// above carries us there.
|
||||
state = 'requested';
|
||||
} catch (e: unknown) {
|
||||
state = 'failed';
|
||||
errorMsg = e instanceof ApiError ? e.message : 'Lidarr request failed';
|
||||
setTimeout(() => {
|
||||
if (state === 'failed') state = 'idle';
|
||||
}, 8000);
|
||||
}
|
||||
}
|
||||
|
||||
const tooltip = $derived.by(() => {
|
||||
if (!isReady) return 'Missing MusicBrainz IDs — cannot request via Lidarr';
|
||||
if (state === 'submitting') return `Requesting "${trackTitle}" via Lidarr…`;
|
||||
if (state === 'downloaded') return `Already in your library — Lidarr downloaded "${trackTitle}"`;
|
||||
if (state === 'requested')
|
||||
return successNote
|
||||
? `Requested via Lidarr (${successNote}). Will appear in library when found.`
|
||||
: `Already requested in Lidarr — waiting for the next grab to land`;
|
||||
if (state === 'failed') return errorMsg ?? 'Lidarr request failed';
|
||||
return `Request "${trackTitle}" via Lidarr (uses your configured indexers)`;
|
||||
});
|
||||
|
||||
// Click is only actionable in idle state. When the request is already
|
||||
// in-flight in Lidarr (or done), the icon is informational.
|
||||
const isActionable = $derived(state === 'idle' || state === 'failed');
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-circle btn-sm btn-ghost {size === 'sm'
|
||||
? 'min-h-[36px] min-w-[36px]'
|
||||
: 'min-h-[44px] min-w-[44px]'} border border-base-content/10 shrink-0 active:scale-[0.95]"
|
||||
class:cursor-default={!isActionable && state !== 'submitting'}
|
||||
title={tooltip}
|
||||
aria-label="Request {trackTitle} via Lidarr"
|
||||
disabled={!isReady}
|
||||
onclick={handleClick}
|
||||
>
|
||||
{#if state === 'submitting'}
|
||||
<Loader2 class="h-4 w-4 animate-spin" color={colors.accent} />
|
||||
{:else if state === 'downloaded'}
|
||||
<Check class="h-4 w-4" color={colors.accent} />
|
||||
{:else if state === 'requested'}
|
||||
<Hourglass class="h-4 w-4" color={colors.accent} />
|
||||
{:else if state === 'failed'}
|
||||
<AlertTriangle class="h-4 w-4 text-error" />
|
||||
{:else}
|
||||
<Library class="h-4 w-4 opacity-70" />
|
||||
{/if}
|
||||
</button>
|
||||
@@ -4,8 +4,14 @@
|
||||
import { API } from '$lib/constants';
|
||||
import { api } from '$lib/api/client';
|
||||
import { playerStore } from '$lib/stores/player.svelte';
|
||||
import { libraryRefresh } from '$lib/stores/libraryRefresh.svelte';
|
||||
import TrackRow from './TrackRow.svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import {
|
||||
getLidarrRequestStatus,
|
||||
projectButtonStatus,
|
||||
type LidarrButtonStatus
|
||||
} from '$lib/api/lidarrRequest';
|
||||
|
||||
interface Props {
|
||||
songs: TopSong[];
|
||||
@@ -25,8 +31,16 @@
|
||||
|
||||
let cacheMap = new SvelteMap<string, boolean>();
|
||||
let resolveMap = new SvelteMap<string, ResolvedTrack>();
|
||||
// Lidarr-side per-track status, keyed by recording_mbid. Built by
|
||||
// fanning out a /lidarr-request/status call per unique album_mbid in
|
||||
// the songs list. Used to render the LidarrRequestButton in its
|
||||
// persistent state (hourglass / checkmark / idle) so songs the user
|
||||
// already requested or downloaded show up that way without a refresh
|
||||
// dropping them back to idle.
|
||||
let lidarrStatusMap = new SvelteMap<string, LidarrButtonStatus>();
|
||||
let lastFetchedKey = $state('');
|
||||
let lastResolveKey = $state('');
|
||||
let lastLidarrStatusKey = $state('');
|
||||
|
||||
function cacheKey(artist: string, track: string): string {
|
||||
return `${artist.toLowerCase()}|${track.toLowerCase()}`;
|
||||
@@ -49,7 +63,10 @@
|
||||
|
||||
$effect(() => {
|
||||
if (!ytConfigured || songs.length === 0) return;
|
||||
const key = songsFingerprint(songs);
|
||||
// Include libraryRefresh.version in the key so the cache-check refetches
|
||||
// after a successful download (TrackDownloadButton bumps the counter on
|
||||
// first-seen status=done).
|
||||
const key = `${libraryRefresh.version}|${songsFingerprint(songs)}`;
|
||||
if (key === lastFetchedKey) return;
|
||||
lastFetchedKey = key;
|
||||
|
||||
@@ -75,7 +92,9 @@
|
||||
$effect(() => {
|
||||
const resolvable = songs.filter((s) => s.release_group_mbid && s.track_number != null);
|
||||
if (resolvable.length === 0) return;
|
||||
const key = resolvableFingerprint(songs);
|
||||
// Same as above — bumping libraryRefresh forces re-resolve so newly-
|
||||
// downloaded tracks flip to the "in library" play icon without a page reload.
|
||||
const key = `${libraryRefresh.version}|${resolvableFingerprint(songs)}`;
|
||||
if (key === lastResolveKey) return;
|
||||
lastResolveKey = key;
|
||||
|
||||
@@ -121,6 +140,44 @@
|
||||
);
|
||||
}
|
||||
|
||||
function lidarrStatusForSong(song: TopSong): LidarrButtonStatus {
|
||||
if (!song.recording_mbid) return 'none';
|
||||
return lidarrStatusMap.get(song.recording_mbid) ?? 'none';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Collect unique album_mbids across all songs in the visible list.
|
||||
// Each one becomes one /lidarr-request/status call. For a typical
|
||||
// 20-song popular list this is ~10–20 albums in parallel; cheap
|
||||
// enough not to need a multi-album batch endpoint yet.
|
||||
const uniqueAlbumMbids = new Set<string>();
|
||||
for (const s of songs) {
|
||||
if (s.release_group_mbid && s.recording_mbid) uniqueAlbumMbids.add(s.release_group_mbid);
|
||||
}
|
||||
if (uniqueAlbumMbids.size === 0) return;
|
||||
|
||||
// Include libraryRefresh.version so the map refreshes after a
|
||||
// successful download — a song goes from `requested` → `downloaded`
|
||||
// without a page reload.
|
||||
const key = `${libraryRefresh.version}|${Array.from(uniqueAlbumMbids).sort().join(';')}`;
|
||||
if (key === lastLidarrStatusKey) return;
|
||||
lastLidarrStatusKey = key;
|
||||
|
||||
(async () => {
|
||||
const responses = await Promise.allSettled(
|
||||
Array.from(uniqueAlbumMbids).map((m) => getLidarrRequestStatus(m))
|
||||
);
|
||||
if (lastLidarrStatusKey !== key) return;
|
||||
for (const r of responses) {
|
||||
if (r.status !== 'fulfilled') continue;
|
||||
for (const t of r.value.tracks) {
|
||||
if (!t.recording_mbid) continue;
|
||||
lidarrStatusMap.set(t.recording_mbid, projectButtonStatus(t));
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
function buildQueueItems(startSong: TopSong): { items: QueueItem[]; startIndex: number } {
|
||||
const items: QueueItem[] = [];
|
||||
let startIndex = 0;
|
||||
@@ -195,6 +252,7 @@
|
||||
{ytConfigured}
|
||||
initialCached={cacheMap.get(cacheKey(song.artist_name, song.title)) ?? null}
|
||||
resolvedTrack={getResolvedTrack(song)}
|
||||
lidarrStatus={lidarrStatusForSong(song)}
|
||||
onPlay={() => handlePlay(song)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
<script lang="ts">
|
||||
import { CloudDownload, Music, X, Check, AlertTriangle, Loader2 } from 'lucide-svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import {
|
||||
searchTrackCandidates,
|
||||
requestTrackDownload,
|
||||
getTrackDownloadJob,
|
||||
TRACK_DOWNLOAD_TERMINAL_STATES,
|
||||
type TrackDownloadCandidate,
|
||||
type TrackDownloadJobStatus,
|
||||
type TrackDownloadStatus,
|
||||
type TrackDownloadSource
|
||||
} from '$lib/api/trackDownload';
|
||||
import { colors } from '$lib/colors';
|
||||
import { formatDurationSec } from '$lib/utils/formatting';
|
||||
import { libraryRefresh } from '$lib/stores/libraryRefresh.svelte';
|
||||
|
||||
interface Props {
|
||||
artist: string;
|
||||
album: string;
|
||||
trackTitle: string;
|
||||
artistMbid?: string | null;
|
||||
trackPosition?: number | null;
|
||||
discNumber?: number | null;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const AUTO_CLOSE_AFTER_DONE_MS = 3000;
|
||||
|
||||
let {
|
||||
artist,
|
||||
album,
|
||||
trackTitle,
|
||||
artistMbid = null,
|
||||
trackPosition = null,
|
||||
discNumber = null,
|
||||
size = 'sm'
|
||||
}: Props = $props();
|
||||
|
||||
let dialogEl: HTMLDialogElement | undefined = $state();
|
||||
let candidates = $state<TrackDownloadCandidate[]>([]);
|
||||
let searching = $state(false);
|
||||
let searchError = $state<string | null>(null);
|
||||
let source = $state<TrackDownloadSource>('spotify');
|
||||
let activeJobId = $state<string | null>(null);
|
||||
let jobStatus = $state<TrackDownloadJobStatus | null>(null);
|
||||
let jobError = $state<string | null>(null);
|
||||
let submitting = $state(false);
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let autoCloseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const isJobActive = $derived(
|
||||
activeJobId !== null && (jobStatus === null || !TRACK_DOWNLOAD_TERMINAL_STATES.has(jobStatus.status))
|
||||
);
|
||||
const isJobDone = $derived(jobStatus?.status === 'done');
|
||||
const isJobFailed = $derived(jobStatus?.status === 'failed');
|
||||
|
||||
function clearTimers() {
|
||||
if (pollTimer !== null) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
if (autoCloseTimer !== null) {
|
||||
clearTimeout(autoCloseTimer);
|
||||
autoCloseTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
clearTimers();
|
||||
candidates = [];
|
||||
searching = false;
|
||||
searchError = null;
|
||||
activeJobId = null;
|
||||
jobStatus = null;
|
||||
jobError = null;
|
||||
submitting = false;
|
||||
}
|
||||
|
||||
async function loadCandidates() {
|
||||
searching = true;
|
||||
searchError = null;
|
||||
candidates = [];
|
||||
try {
|
||||
const query = `${artist} ${trackTitle}`.trim();
|
||||
const res = await searchTrackCandidates(query, 5, source);
|
||||
candidates = res.candidates;
|
||||
if (candidates.length === 0) {
|
||||
const label = source === 'spotify' ? 'Spotify' : 'YouTube';
|
||||
searchError = `No ${label} matches for "${query}"`;
|
||||
}
|
||||
} catch (e) {
|
||||
searchError = e instanceof ApiError ? e.message : 'Search failed';
|
||||
} finally {
|
||||
searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function switchSource(next: TrackDownloadSource) {
|
||||
if (next === source || searching || submitting) return;
|
||||
source = next;
|
||||
loadCandidates();
|
||||
}
|
||||
|
||||
async function pollJob() {
|
||||
if (activeJobId === null) return;
|
||||
try {
|
||||
const status = await getTrackDownloadJob(activeJobId);
|
||||
const wasTerminal =
|
||||
jobStatus !== null && TRACK_DOWNLOAD_TERMINAL_STATES.has(jobStatus.status);
|
||||
jobStatus = status;
|
||||
if (TRACK_DOWNLOAD_TERMINAL_STATES.has(status.status)) {
|
||||
if (status.status === 'done' && !wasTerminal) {
|
||||
// First-seen transition to done — tell any library-derived UI
|
||||
// (TopSongsList resolveMap etc.) to refetch. Backend caches were
|
||||
// already busted by the download-complete fan-out; this just
|
||||
// kicks the frontend out of its own per-component cache.
|
||||
libraryRefresh.bump();
|
||||
autoCloseTimer = setTimeout(() => {
|
||||
closeDialog();
|
||||
}, AUTO_CLOSE_AFTER_DONE_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
pollTimer = setTimeout(pollJob, POLL_INTERVAL_MS);
|
||||
} catch (e) {
|
||||
jobError = e instanceof ApiError ? e.message : 'Failed to poll status';
|
||||
}
|
||||
}
|
||||
|
||||
async function pickCandidate(c: TrackDownloadCandidate) {
|
||||
if (submitting || isJobActive) return;
|
||||
submitting = true;
|
||||
jobError = null;
|
||||
try {
|
||||
const res = await requestTrackDownload({
|
||||
video_id: c.video_id,
|
||||
source: c.source,
|
||||
target_duration_seconds: c.duration_seconds,
|
||||
artist,
|
||||
album,
|
||||
track_title: trackTitle,
|
||||
artist_mbid: artistMbid,
|
||||
track_position: trackPosition,
|
||||
disc_number: discNumber
|
||||
});
|
||||
activeJobId = res.job_id;
|
||||
jobStatus = null;
|
||||
pollJob();
|
||||
} catch (e) {
|
||||
jobError = e instanceof ApiError ? e.message : 'Failed to start download';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function openDialog() {
|
||||
if (!dialogEl) return;
|
||||
// Preserve in-flight job; only reset if previous run completed.
|
||||
if (!isJobActive && (jobStatus === null || TRACK_DOWNLOAD_TERMINAL_STATES.has(jobStatus.status))) {
|
||||
resetState();
|
||||
loadCandidates();
|
||||
}
|
||||
dialogEl.showModal();
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
if (!dialogEl) return;
|
||||
dialogEl.close();
|
||||
// If job finished (done or failed), reset on close so next open is fresh.
|
||||
if (jobStatus !== null && TRACK_DOWNLOAD_TERMINAL_STATES.has(jobStatus.status)) {
|
||||
resetState();
|
||||
}
|
||||
}
|
||||
|
||||
function handleButtonClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
openDialog();
|
||||
}
|
||||
|
||||
function statusLabel(s: TrackDownloadStatus): string {
|
||||
switch (s) {
|
||||
case 'queued':
|
||||
return 'Queued';
|
||||
case 'searching':
|
||||
return 'Searching';
|
||||
case 'downloading':
|
||||
return 'Downloading';
|
||||
case 'tagging':
|
||||
return 'Tagging';
|
||||
case 'importing':
|
||||
return 'Adding to library';
|
||||
case 'done':
|
||||
return 'Done';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
default:
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(clearTimers);
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-circle btn-sm btn-ghost {size === 'sm'
|
||||
? 'min-h-[36px] min-w-[36px]'
|
||||
: 'min-h-[44px] min-w-[44px]'} border border-base-content/10 shrink-0 active:scale-[0.95]"
|
||||
title={isJobActive
|
||||
? `Track download: ${jobStatus ? statusLabel(jobStatus.status) : 'queued'}`
|
||||
: isJobDone
|
||||
? 'Downloaded'
|
||||
: isJobFailed
|
||||
? 'Last download failed — click to retry'
|
||||
: `Download "${trackTitle}"`}
|
||||
aria-label="Download {trackTitle}"
|
||||
onclick={handleButtonClick}
|
||||
>
|
||||
{#if isJobActive}
|
||||
<Loader2 class="h-4 w-4 animate-spin" color={colors.accent} />
|
||||
{:else if isJobDone}
|
||||
<Check class="h-4 w-4" color={colors.accent} />
|
||||
{:else if isJobFailed}
|
||||
<AlertTriangle class="h-4 w-4 text-error" />
|
||||
{:else}
|
||||
<CloudDownload class="h-4 w-4 opacity-70" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<dialog bind:this={dialogEl} class="modal">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<form method="dialog">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||
aria-label="Close"
|
||||
onclick={closeDialog}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg"
|
||||
style="background-color: {colors.accent}20;"
|
||||
>
|
||||
<Music class="h-6 w-6" color={colors.accent} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Download single track</div>
|
||||
<div class="truncate text-lg font-semibold">{trackTitle}</div>
|
||||
<div class="truncate text-sm opacity-70">{artist} — {album}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex justify-end">
|
||||
<div class="join" role="tablist" aria-label="Search source">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={source === 'spotify'}
|
||||
class="btn btn-xs join-item {source === 'spotify' ? 'btn-active' : ''}"
|
||||
disabled={searching || submitting || isJobActive}
|
||||
onclick={() => switchSource('spotify')}
|
||||
>
|
||||
Spotify
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={source === 'youtube'}
|
||||
class="btn btn-xs join-item {source === 'youtube' ? 'btn-active' : ''}"
|
||||
disabled={searching || submitting || isJobActive}
|
||||
onclick={() => switchSource('youtube')}
|
||||
>
|
||||
YouTube
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider my-3"></div>
|
||||
|
||||
{#if activeJobId !== null}
|
||||
<!-- Job in progress / terminal -->
|
||||
<div class="space-y-3 py-2">
|
||||
{#if isJobDone}
|
||||
<div class="flex items-center gap-3 text-success">
|
||||
<Check class="h-6 w-6" />
|
||||
<div>
|
||||
<div class="font-semibold">Download complete</div>
|
||||
<div class="text-xs opacity-70">
|
||||
{jobStatus?.file_path ?? 'File saved'} — Plex library refresh triggered.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if isJobFailed}
|
||||
<div class="flex items-start gap-3 text-error">
|
||||
<AlertTriangle class="h-6 w-6 shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold">Download failed</div>
|
||||
<div class="break-words text-xs opacity-80">
|
||||
{jobStatus?.error ?? jobError ?? 'Unknown error'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-3">
|
||||
<Loader2 class="h-5 w-5 animate-spin" color={colors.accent} />
|
||||
<div class="text-sm">
|
||||
<span class="font-medium">{statusLabel(jobStatus?.status ?? 'queued')}</span>
|
||||
<span class="opacity-60"> — yt-dlp working on it...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs opacity-50">
|
||||
This usually takes 20–60 seconds. You can close this dialog and the job will keep running.
|
||||
</div>
|
||||
{/if}
|
||||
{#if jobError && !isJobFailed}
|
||||
<div class="text-xs text-error">{jobError}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if searching}
|
||||
<div class="flex items-center gap-3 py-6">
|
||||
<span class="loading loading-spinner loading-md" style="color: {colors.accent};"></span>
|
||||
<span class="text-sm opacity-70">
|
||||
Searching {source === 'spotify' ? 'Spotify' : 'YouTube'} for matches...
|
||||
</span>
|
||||
</div>
|
||||
{:else if searchError}
|
||||
<div class="alert alert-warning text-sm">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
{searchError}
|
||||
</div>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<button type="button" class="btn btn-sm" onclick={loadCandidates}>Retry search</button>
|
||||
</div>
|
||||
{:else if candidates.length > 0}
|
||||
<div class="text-xs opacity-60">
|
||||
{#if source === 'spotify'}
|
||||
Pick a Spotify match — the worker will find the matching YouTube audio and tag it with
|
||||
Spotify metadata.
|
||||
{:else}
|
||||
Pick the best YouTube match — yt-dlp will download it to your music library.
|
||||
{/if}
|
||||
</div>
|
||||
<ul class="mt-2 space-y-1">
|
||||
{#each candidates as c (c.video_id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-block h-auto justify-start gap-3 px-3 py-2 normal-case"
|
||||
disabled={submitting}
|
||||
onclick={() => pickCandidate(c)}
|
||||
>
|
||||
{#if c.thumbnail_url}
|
||||
<img
|
||||
src={c.thumbnail_url}
|
||||
alt=""
|
||||
class="h-12 w-20 shrink-0 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="h-12 w-20 shrink-0 rounded bg-base-300"></div>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="truncate font-medium">{c.title}</div>
|
||||
<div class="truncate text-xs opacity-60">
|
||||
{#if c.source === 'spotify'}
|
||||
{c.artist ?? 'Unknown artist'}{#if c.album} · {c.album}{/if}
|
||||
{:else}
|
||||
{c.channel ?? 'Unknown channel'}
|
||||
{/if}
|
||||
{#if c.duration_seconds !== null} · {formatDurationSec(c.duration_seconds)}{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="submit" aria-label="close" onclick={closeDialog}>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
@@ -1,10 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { albumHref } from '$lib/utils/entityRoutes';
|
||||
import { Play, Disc3 } from 'lucide-svelte';
|
||||
import type { TopSong, ResolvedTrack } from '$lib/types';
|
||||
import type { TopSong, ResolvedTrack, TrackButtonVisibility } from '$lib/types';
|
||||
import AlbumImage from './AlbumImage.svelte';
|
||||
import LastFmPlaceholder from './LastFmPlaceholder.svelte';
|
||||
import TrackPreviewButton from './TrackPreviewButton.svelte';
|
||||
import TrackDownloadButton from './TrackDownloadButton.svelte';
|
||||
import LidarrRequestButton from './LidarrRequestButton.svelte';
|
||||
import { preferencesStore } from '$lib/stores/preferences';
|
||||
import type { LidarrButtonStatus } from '$lib/api/lidarrRequest';
|
||||
|
||||
interface Props {
|
||||
song: TopSong;
|
||||
@@ -14,6 +18,7 @@
|
||||
ytConfigured?: boolean;
|
||||
initialCached?: boolean | null;
|
||||
resolvedTrack?: ResolvedTrack | null;
|
||||
lidarrStatus?: LidarrButtonStatus;
|
||||
onPlay?: () => void;
|
||||
}
|
||||
|
||||
@@ -25,6 +30,7 @@
|
||||
ytConfigured = false,
|
||||
initialCached = null,
|
||||
resolvedTrack = null,
|
||||
lidarrStatus = 'none',
|
||||
onPlay
|
||||
}: Props = $props();
|
||||
|
||||
@@ -32,6 +38,28 @@
|
||||
let isLastfmNoAlbum = $derived(!hasAlbum && source === 'lastfm');
|
||||
let canPlay = $derived(!!resolvedTrack?.source);
|
||||
let previewEnabled = $derived(showPreview && ytConfigured && !canPlay);
|
||||
// Worker requires a non-empty album for the file path; bucket release-less
|
||||
// tracks under "Singles" so the download still lands somewhere sensible.
|
||||
let downloadAlbum = $derived(song.release_name || 'Singles');
|
||||
|
||||
// Subscribe to the user's per-context button-visibility preferences.
|
||||
// `popular_songs` is the relevant slot for TrackRow (used by the
|
||||
// Popular Songs panel on artist pages). Defaults all-true so first
|
||||
// paint matches pre-fork behavior; the server response replaces this
|
||||
// on load.
|
||||
let buttonVisibility = $state<TrackButtonVisibility>({
|
||||
lidarr_request: true,
|
||||
track_download: true,
|
||||
preview: true,
|
||||
yt_play: true,
|
||||
jellyfin: true,
|
||||
local_files: true,
|
||||
navidrome: true,
|
||||
plex: true
|
||||
});
|
||||
preferencesStore.subscribe((prefs) => {
|
||||
buttonVisibility = prefs.download_options.popular_songs;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if hasAlbum}
|
||||
@@ -39,13 +67,11 @@
|
||||
{#if canPlay}
|
||||
<button
|
||||
onclick={onPlay}
|
||||
class="w-6 shrink-0 flex items-center justify-center cursor-pointer"
|
||||
aria-label="Play {song.title}"
|
||||
class="w-6 shrink-0 flex items-center justify-center cursor-pointer text-primary"
|
||||
aria-label="Play {song.title} (in library)"
|
||||
title="In library — click to play"
|
||||
>
|
||||
<span class="group-hover:hidden text-sm text-base-content/50">{position}</span>
|
||||
<span class="hidden group-hover:block text-primary">
|
||||
<Play class="w-4 h-4 mx-auto fill-current" />
|
||||
</span>
|
||||
<Play class="w-4 h-4 mx-auto fill-current" />
|
||||
</button>
|
||||
{:else if previewEnabled}
|
||||
<span class="w-6 shrink-0 flex items-center justify-center">
|
||||
@@ -93,6 +119,40 @@
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!--
|
||||
Action cluster — LidarrRequestButton + TrackDownloadButton.
|
||||
ALWAYS visible at full opacity, no hover dependency. Hover-to-reveal
|
||||
UX confused users on mobile (no hover state at all) and on desktop
|
||||
(per-user preference 2026-05-29). The "already in library" affordance
|
||||
lives in the button title attribute instead.
|
||||
-->
|
||||
<div
|
||||
class="shrink-0 flex items-center gap-1"
|
||||
title={canPlay ? 'Already in library — download again only if you need a fresh copy' : ''}
|
||||
>
|
||||
{#if buttonVisibility.lidarr_request && song.recording_mbid && song.release_group_mbid}
|
||||
<LidarrRequestButton
|
||||
albumMbid={song.release_group_mbid}
|
||||
trackMbid={song.recording_mbid}
|
||||
trackTitle={song.title}
|
||||
trackPosition={song.track_number ?? null}
|
||||
discNumber={song.disc_number ?? null}
|
||||
initialStatus={lidarrStatus}
|
||||
size="sm"
|
||||
/>
|
||||
{/if}
|
||||
{#if buttonVisibility.track_download}
|
||||
<TrackDownloadButton
|
||||
artist={song.artist_name}
|
||||
album={downloadAlbum}
|
||||
trackTitle={song.title}
|
||||
trackPosition={song.track_number ?? null}
|
||||
discNumber={song.disc_number ?? null}
|
||||
size="sm"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
@@ -103,13 +163,11 @@
|
||||
{#if canPlay}
|
||||
<button
|
||||
onclick={onPlay}
|
||||
class="w-6 shrink-0 flex items-center justify-center cursor-pointer"
|
||||
aria-label="Play {song.title}"
|
||||
class="w-6 shrink-0 flex items-center justify-center cursor-pointer text-primary"
|
||||
aria-label="Play {song.title} (in library)"
|
||||
title="In library — click to play"
|
||||
>
|
||||
<span class="group-hover:hidden text-sm text-base-content/50">{position}</span>
|
||||
<span class="hidden group-hover:block text-primary">
|
||||
<Play class="w-4 h-4 mx-auto fill-current" />
|
||||
</span>
|
||||
<Play class="w-4 h-4 mx-auto fill-current" />
|
||||
</button>
|
||||
{:else if previewEnabled}
|
||||
<span class="w-6 shrink-0 flex items-center justify-center">
|
||||
@@ -140,5 +198,22 @@
|
||||
<p class="font-medium text-sm truncate min-w-0">{song.title}</p>
|
||||
<p class="text-xs text-base-content/40 truncate min-w-0 text-right italic"></p>
|
||||
</div>
|
||||
|
||||
<!-- Always visible at full opacity, matching the canonical cluster above. -->
|
||||
<div
|
||||
class="shrink-0"
|
||||
title={canPlay ? 'Already in library — download again only if you need a fresh copy' : ''}
|
||||
>
|
||||
{#if buttonVisibility.track_download}
|
||||
<TrackDownloadButton
|
||||
artist={song.artist_name}
|
||||
album={downloadAlbum}
|
||||
trackTitle={song.title}
|
||||
trackPosition={song.track_number ?? null}
|
||||
discNumber={song.disc_number ?? null}
|
||||
size="sm"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 14 KiB |
@@ -3,6 +3,8 @@
|
||||
import { createSettingsForm } from '$lib/utils/settingsForm.svelte';
|
||||
import { invalidateQueriesWithPersister } from '$lib/queries/QueryClient';
|
||||
import { HomeQueryKeyFactory } from '$lib/queries/HomeQueryKeyFactory';
|
||||
import { homeSettingsStore } from '$lib/stores/homeSettings.svelte';
|
||||
import { nowPlayingStore } from '$lib/stores/nowPlayingSessions.svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
const form = createSettingsForm<HomeSettings>({
|
||||
@@ -10,6 +12,12 @@
|
||||
saveEndpoint: '/api/v1/settings/home',
|
||||
afterSave: async () => {
|
||||
await invalidateQueriesWithPersister({ queryKey: HomeQueryKeyFactory.prefix });
|
||||
// Refresh the cross-cutting store so nowPlayingSessions.fetchAll()
|
||||
// picks up the new value on its next tick — and trigger an
|
||||
// immediate poll so the banner appears/disappears without the
|
||||
// user having to wait the 3s interval.
|
||||
await homeSettingsStore.refresh();
|
||||
void nowPlayingStore.refresh();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -55,6 +63,26 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={form.data.show_now_playing}
|
||||
class="toggle toggle-primary"
|
||||
/>
|
||||
<div>
|
||||
<span class="label-text font-medium">Show currently listening</span>
|
||||
<p class="text-xs text-base-content/50">
|
||||
Shows the now-playing banner on the home page, the sidebar listening
|
||||
indicator, and the active-sessions widget on each library page. Off by
|
||||
default for shared instances — Plex returns sessions across the whole
|
||||
server, so leaving it on can leak other household members' listening
|
||||
activity. Your own playback inside MusicSeerr is always visible.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if form.message}
|
||||
<div
|
||||
class="alert"
|
||||
|
||||
@@ -8,10 +8,13 @@ vi.mock('$env/dynamic/public', () => ({
|
||||
|
||||
import SettingsHome from './SettingsHome.svelte';
|
||||
|
||||
function mockHomeSettings(overrides: { show_whats_hot?: boolean } = {}) {
|
||||
function mockHomeSettings(
|
||||
overrides: { show_whats_hot?: boolean; show_now_playing?: boolean } = {}
|
||||
) {
|
||||
return {
|
||||
show_whats_hot: true,
|
||||
show_globally_trending: true,
|
||||
show_now_playing: false,
|
||||
cache_ttl_trending: 3600,
|
||||
cache_ttl_personal: 300,
|
||||
...overrides
|
||||
@@ -110,4 +113,38 @@ describe('SettingsHome.svelte', () => {
|
||||
expect(toggle.checked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders Show currently listening toggle', async () => {
|
||||
globalThis.fetch = mockJsonResponse(mockHomeSettings());
|
||||
render(SettingsHome);
|
||||
|
||||
const label = page.getByText('Show currently listening');
|
||||
await expect.element(label).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('currently-listening toggle defaults to the loaded value (off)', async () => {
|
||||
globalThis.fetch = mockJsonResponse(mockHomeSettings({ show_now_playing: false }));
|
||||
render(SettingsHome);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const toggles = document.querySelectorAll(
|
||||
'input[type="checkbox"].toggle'
|
||||
) as NodeListOf<HTMLInputElement>;
|
||||
expect(toggles.length).toBeGreaterThanOrEqual(2);
|
||||
expect(toggles[1].checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('currently-listening toggle reflects loaded state when on', async () => {
|
||||
globalThis.fetch = mockJsonResponse(mockHomeSettings({ show_now_playing: true }));
|
||||
render(SettingsHome);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const toggles = document.querySelectorAll(
|
||||
'input[type="checkbox"].toggle'
|
||||
) as NodeListOf<HTMLInputElement>;
|
||||
expect(toggles.length).toBeGreaterThanOrEqual(2);
|
||||
expect(toggles[1].checked).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
UserPreferences,
|
||||
ReleaseTypeOption,
|
||||
LidarrMetadataProfilePreferences,
|
||||
MetadataProfile
|
||||
MetadataProfile,
|
||||
TrackButtonKey,
|
||||
DownloadOptionsContext
|
||||
} from '$lib/types';
|
||||
import { invalidateQueriesWithPersister } from '$lib/queries/QueryClient';
|
||||
import { ArtistQueryKeyFactory } from '$lib/queries/artist/ArtistQueryKeyFactory';
|
||||
@@ -14,7 +16,31 @@
|
||||
let preferences: UserPreferences = $state({
|
||||
primary_types: [],
|
||||
secondary_types: [],
|
||||
release_statuses: []
|
||||
release_statuses: [],
|
||||
// Defaults all-on — mirrors backend.TrackButtonVisibility defaults
|
||||
// and pre-fork behavior. Replaced by the server response on load.
|
||||
download_options: {
|
||||
popular_songs: {
|
||||
lidarr_request: true,
|
||||
track_download: true,
|
||||
preview: true,
|
||||
yt_play: true,
|
||||
jellyfin: true,
|
||||
local_files: true,
|
||||
navidrome: true,
|
||||
plex: true
|
||||
},
|
||||
album_page: {
|
||||
lidarr_request: true,
|
||||
track_download: true,
|
||||
preview: true,
|
||||
yt_play: true,
|
||||
jellyfin: true,
|
||||
local_files: true,
|
||||
navidrome: true,
|
||||
plex: true
|
||||
}
|
||||
}
|
||||
});
|
||||
let saving = $state(false);
|
||||
let saveMessage = $state('');
|
||||
@@ -62,6 +88,94 @@
|
||||
{ id: 'pseudo-release', title: 'Pseudo-Release', description: 'Placeholder or meta releases' }
|
||||
];
|
||||
|
||||
// Per-button metadata for the Download Options table. `contexts` lists
|
||||
// where the button actually renders today — the toggle is hidden for
|
||||
// contexts where the button isn't rendered (e.g., the Popular Songs
|
||||
// row only shows lidarr_request + track_download). Keeping the schema
|
||||
// carrying all 8 keys in both contexts means a future expansion (e.g.
|
||||
// adding Plex playback to Popular Songs) needs no migration — just
|
||||
// flip the `contexts` array here.
|
||||
type TrackButtonOption = {
|
||||
key: TrackButtonKey;
|
||||
title: string;
|
||||
description: string;
|
||||
contexts: DownloadOptionsContext[];
|
||||
};
|
||||
|
||||
// "Download" buttons — actions that pull a track into the library.
|
||||
// TrackDownloadButton already covers both YouTube and Spotify behind
|
||||
// its own source picker, so it counts as one row regardless of source.
|
||||
const downloadButtons: TrackButtonOption[] = [
|
||||
{
|
||||
key: 'lidarr_request',
|
||||
title: 'Request via Lidarr',
|
||||
description: 'Adds the track to Lidarr and triggers a search through your configured indexers.',
|
||||
contexts: ['popular_songs', 'album_page']
|
||||
},
|
||||
{
|
||||
key: 'track_download',
|
||||
title: 'Direct download (yt-dlp)',
|
||||
description:
|
||||
'Grabs the track via the yt-dlp-worker. The button itself has an internal YouTube / Spotify source picker.',
|
||||
contexts: ['popular_songs', 'album_page']
|
||||
}
|
||||
];
|
||||
|
||||
// "Playback" buttons — listen / queue / play, no library write. Each
|
||||
// is still source-availability-gated (the Jellyfin button only renders
|
||||
// when a Jellyfin server is configured AND the track is mapped to a
|
||||
// file there); unchecking here force-hides on top of that gate.
|
||||
const playbackButtons: TrackButtonOption[] = [
|
||||
{
|
||||
key: 'preview',
|
||||
title: 'YouTube preview',
|
||||
description: 'Inline preview/scrub of the track on YouTube without leaving the page.',
|
||||
contexts: ['album_page']
|
||||
},
|
||||
{
|
||||
key: 'yt_play',
|
||||
title: 'YouTube play',
|
||||
description: 'Queue the track for playback via the YouTube player.',
|
||||
contexts: ['album_page']
|
||||
},
|
||||
{
|
||||
key: 'jellyfin',
|
||||
title: 'Jellyfin',
|
||||
description: 'Play the track from your Jellyfin server when available.',
|
||||
contexts: ['album_page']
|
||||
},
|
||||
{
|
||||
key: 'local_files',
|
||||
title: 'Local files',
|
||||
description: 'Play the track from the local-files library when available.',
|
||||
contexts: ['album_page']
|
||||
},
|
||||
{
|
||||
key: 'navidrome',
|
||||
title: 'Navidrome',
|
||||
description: 'Play the track from your Navidrome server when available.',
|
||||
contexts: ['album_page']
|
||||
},
|
||||
{
|
||||
key: 'plex',
|
||||
title: 'Plex',
|
||||
description: 'Play the track from your Plex server when available.',
|
||||
contexts: ['album_page']
|
||||
}
|
||||
];
|
||||
|
||||
function toggleDownloadOption(context: DownloadOptionsContext, key: TrackButtonKey): void {
|
||||
// Object-spread re-assignment for Svelte 5 deep-reactivity safety —
|
||||
// matches the immutable update style used by toggleType above.
|
||||
preferences.download_options = {
|
||||
...preferences.download_options,
|
||||
[context]: {
|
||||
...preferences.download_options[context],
|
||||
[key]: !preferences.download_options[context][key]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toggleType(
|
||||
category: 'primary_types' | 'secondary_types' | 'release_statuses',
|
||||
id: string
|
||||
@@ -308,6 +422,46 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet trackButtonTable(buttons: TrackButtonOption[], context: DownloadOptionsContext)}
|
||||
{@const rows = buttons.filter((b) => b.contexts.includes(context))}
|
||||
{#if rows.length === 0}
|
||||
<p class="text-sm text-base-content/60 italic">
|
||||
(none of these buttons render in this slot today)
|
||||
</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-12 text-center">
|
||||
<span class="text-xs opacity-60">Show</span>
|
||||
</th>
|
||||
<th>Button</th>
|
||||
<th class="hidden sm:table-cell">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as btn (btn.key)}
|
||||
{@const enabled = preferences.download_options[context][btn.key]}
|
||||
<tr>
|
||||
<td class="w-12 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary checkbox-sm"
|
||||
checked={enabled}
|
||||
onchange={() => toggleDownloadOption(context, btn.key)}
|
||||
/>
|
||||
</td>
|
||||
<td class="font-medium">{btn.title}</td>
|
||||
<td class="text-base-content/70 hidden sm:table-cell">{btn.description}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-4">Included Releases</h2>
|
||||
@@ -387,25 +541,67 @@
|
||||
<h3 class="text-xl font-semibold mb-4">Release Statuses</h3>
|
||||
{@render typeTable(releaseStatuses, 'release_statuses')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end items-center gap-4">
|
||||
{#if saveMessage}
|
||||
<div
|
||||
class="alert flex-1"
|
||||
class:alert-success={saveMessage.includes('success')}
|
||||
class:alert-error={saveMessage.includes('Failed')}
|
||||
>
|
||||
<span>{saveMessage}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<button class="btn btn-primary" onclick={handleSave} disabled={saving}>
|
||||
{#if saving}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Saving...
|
||||
{:else}
|
||||
Save Settings
|
||||
{/if}
|
||||
</button>
|
||||
<div class="card bg-base-200 mt-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-4">Download Options</h2>
|
||||
<p class="text-base-content/70 mb-6">
|
||||
Choose which <em>download</em> buttons appear next to each track. Direct download already
|
||||
covers both YouTube and Spotify via the button's own source picker, so it counts once.
|
||||
Playback / preview buttons live in the separate card below.
|
||||
</p>
|
||||
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4">Album Track List</h3>
|
||||
{@render trackButtonTable(downloadButtons, 'album_page')}
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4">Popular Songs</h3>
|
||||
{@render trackButtonTable(downloadButtons, 'popular_songs')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 mt-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-4">Playback Buttons</h2>
|
||||
<p class="text-base-content/70 mb-6">
|
||||
Choose which <em>playback</em> buttons appear next to each track. Unchecking forces a button
|
||||
hidden; checking lets the existing source-availability gate decide (e.g., the Jellyfin button
|
||||
still only renders when a Jellyfin server is configured and the track is mapped there).
|
||||
</p>
|
||||
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4">Album Track List</h3>
|
||||
{@render trackButtonTable(playbackButtons, 'album_page')}
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4">Popular Songs</h3>
|
||||
{@render trackButtonTable(playbackButtons, 'popular_songs')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end items-center gap-4 mt-6">
|
||||
{#if saveMessage}
|
||||
<div
|
||||
class="alert flex-1"
|
||||
class:alert-success={saveMessage.includes('success')}
|
||||
class:alert-error={saveMessage.includes('Failed')}
|
||||
>
|
||||
<span>{saveMessage}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<button class="btn btn-primary" onclick={handleSave} disabled={saving}>
|
||||
{#if saving}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Saving...
|
||||
{:else}
|
||||
Save Settings
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { api } from '$lib/api/client';
|
||||
import type { HomeSettings } from '$lib/types';
|
||||
|
||||
const API = '/api/v1/settings/home';
|
||||
|
||||
interface HomeSettingsState extends HomeSettings {
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
const defaults: HomeSettingsState = {
|
||||
cache_ttl_trending: 3600,
|
||||
cache_ttl_personal: 300,
|
||||
show_whats_hot: true,
|
||||
show_globally_trending: true,
|
||||
show_now_playing: false,
|
||||
loaded: false
|
||||
};
|
||||
|
||||
function createHomeSettingsStore() {
|
||||
const { subscribe, set, update } = writable<HomeSettingsState>(defaults);
|
||||
let loadPromise: Promise<void> | null = null;
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
const settings = await api.global.get<HomeSettings>(API);
|
||||
update((state) => ({ ...state, ...settings, loaded: true }));
|
||||
} catch {
|
||||
update((state) => ({ ...state, loaded: true }));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
load,
|
||||
refresh: load,
|
||||
ensureLoaded: async (): Promise<void> => {
|
||||
const current = get({ subscribe });
|
||||
if (current.loaded) return;
|
||||
if (loadPromise) return loadPromise;
|
||||
|
||||
loadPromise = load().finally(() => {
|
||||
loadPromise = null;
|
||||
});
|
||||
return loadPromise;
|
||||
},
|
||||
get showNowPlaying(): boolean {
|
||||
return get({ subscribe }).show_now_playing;
|
||||
},
|
||||
reset: () => set(defaults)
|
||||
};
|
||||
}
|
||||
|
||||
export const homeSettingsStore = createHomeSettingsStore();
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Monotonically-increasing counter that components can subscribe to in order
|
||||
* to know "the local-library state may have changed; re-fetch anything that
|
||||
* depends on it."
|
||||
*
|
||||
* Mechanism: TrackDownloadButton calls `bump()` on first-seen `status=done`
|
||||
* for a download. Components that render library-derived state (notably
|
||||
* TopSongsList's resolveMap) include `libraryRefresh.version` in their
|
||||
* effect-dependency key, so the effect re-runs when the counter ticks.
|
||||
*
|
||||
* Cheap, ephemeral (in-memory, lost on reload), and decoupled — the producer
|
||||
* (download flow) and consumers (any future component showing in-library state)
|
||||
* don't need to know about each other.
|
||||
*/
|
||||
|
||||
function createLibraryRefreshStore() {
|
||||
let version = $state(0);
|
||||
|
||||
return {
|
||||
get version(): number {
|
||||
return version;
|
||||
},
|
||||
|
||||
bump(): void {
|
||||
version += 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const libraryRefresh = createLibraryRefreshStore();
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
vi.mock('$env/dynamic/public', () => ({
|
||||
env: { PUBLIC_API_URL: '' }
|
||||
}));
|
||||
|
||||
// integrationStore needs to be a real Svelte store — nowPlayingSessions uses
|
||||
// `get(integrationStore)` to decide which sources to poll.
|
||||
const integrationState = writable({
|
||||
jellyfin: false,
|
||||
navidrome: false,
|
||||
plex: true,
|
||||
loaded: true
|
||||
});
|
||||
vi.mock('$lib/stores/integration', () => ({
|
||||
integrationStore: integrationState
|
||||
}));
|
||||
|
||||
let showNowPlaying = false;
|
||||
vi.mock('$lib/stores/homeSettings.svelte', () => ({
|
||||
homeSettingsStore: {
|
||||
get showNowPlaying() {
|
||||
return showNowPlaying;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
describe('nowPlayingStore privacy gate', () => {
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('skips the network when showNowPlaying is false', async () => {
|
||||
showNowPlaying = false;
|
||||
const fetchSpy = vi.fn();
|
||||
globalThis.fetch = fetchSpy;
|
||||
|
||||
const { nowPlayingStore } = await import('./nowPlayingSessions.svelte');
|
||||
await nowPlayingStore.refresh();
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(nowPlayingStore.sessions.length).toBe(0);
|
||||
});
|
||||
|
||||
it('hits the configured source when showNowPlaying is true', async () => {
|
||||
showNowPlaying = true;
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ sessions: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchSpy;
|
||||
|
||||
const { nowPlayingStore } = await import('./nowPlayingSessions.svelte');
|
||||
await nowPlayingStore.refresh();
|
||||
|
||||
// Plex is the only integration configured in the mock above — exactly
|
||||
// one fetch should land on the Plex sessions endpoint.
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchSpy.mock.calls[0][0])).toContain('plex');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { API } from '$lib/constants';
|
||||
import { integrationStore } from '$lib/stores/integration';
|
||||
import { homeSettingsStore } from '$lib/stores/homeSettings.svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import type {
|
||||
@@ -113,6 +114,17 @@ function createNowPlayingStore() {
|
||||
async function fetchAll() {
|
||||
if (typeof document !== 'undefined' && document.hidden) return;
|
||||
|
||||
// Privacy gate: when the home setting is off, drop any cached server
|
||||
// sessions and skip the network fetch entirely. The merged store
|
||||
// still emits the user's local MusicSeerr playback because that is
|
||||
// built from playerStore, not from this feed.
|
||||
if (!homeSettingsStore.showNowPlaying) {
|
||||
if (sessions.length > 0) sessions = [];
|
||||
lastGoodSessions.clear();
|
||||
interpBasis.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const integrations = get(integrationStore);
|
||||
const fetches: Promise<{
|
||||
source: SourceKey;
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { UserPreferences } from '$lib/types';
|
||||
import type { UserPreferences, TrackButtonVisibility } from '$lib/types';
|
||||
import { api } from '$lib/api/client';
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
// All-true visibility — matches backend.TrackButtonVisibility defaults.
|
||||
// Used as the client-side default so first paint (before /preferences
|
||||
// resolves) renders the full cluster, matching pre-fork behavior. The
|
||||
// server's response replaces this on load.
|
||||
const allVisible: TrackButtonVisibility = {
|
||||
lidarr_request: true,
|
||||
track_download: true,
|
||||
preview: true,
|
||||
yt_play: true,
|
||||
jellyfin: true,
|
||||
local_files: true,
|
||||
navidrome: true,
|
||||
plex: true
|
||||
};
|
||||
|
||||
const defaultPreferences: UserPreferences = {
|
||||
primary_types: ['album', 'ep', 'single'],
|
||||
secondary_types: ['studio'],
|
||||
release_statuses: ['official']
|
||||
release_statuses: ['official'],
|
||||
download_options: {
|
||||
popular_songs: { ...allVisible },
|
||||
album_page: { ...allVisible }
|
||||
}
|
||||
};
|
||||
|
||||
const { subscribe, set, update } = writable<UserPreferences>(defaultPreferences);
|
||||
|
||||
@@ -173,10 +173,34 @@ export type ArtistReleases = {
|
||||
source_total_count: number | null;
|
||||
};
|
||||
|
||||
// Mirrors backend.TrackButtonVisibility — per-context force-off flags
|
||||
// for the track-row action cluster. true = let the existing
|
||||
// source-availability gate decide; false = always hide.
|
||||
export type TrackButtonVisibility = {
|
||||
lidarr_request: boolean;
|
||||
track_download: boolean;
|
||||
preview: boolean;
|
||||
yt_play: boolean;
|
||||
jellyfin: boolean;
|
||||
local_files: boolean;
|
||||
navidrome: boolean;
|
||||
plex: boolean;
|
||||
};
|
||||
|
||||
export type TrackButtonKey = keyof TrackButtonVisibility;
|
||||
|
||||
export type DownloadOptions = {
|
||||
popular_songs: TrackButtonVisibility;
|
||||
album_page: TrackButtonVisibility;
|
||||
};
|
||||
|
||||
export type DownloadOptionsContext = keyof DownloadOptions;
|
||||
|
||||
export type UserPreferences = {
|
||||
primary_types: string[];
|
||||
secondary_types: string[];
|
||||
release_statuses: string[];
|
||||
download_options: DownloadOptions;
|
||||
};
|
||||
|
||||
export type ReleaseTypeOption = {
|
||||
@@ -273,6 +297,7 @@ export type HomeSettings = {
|
||||
cache_ttl_personal: number;
|
||||
show_whats_hot: boolean;
|
||||
show_globally_trending: boolean;
|
||||
show_now_playing: boolean;
|
||||
};
|
||||
|
||||
export type HomeArtist = {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { errorModal } from '$lib/stores/errorModal';
|
||||
import { libraryStore } from '$lib/stores/library';
|
||||
import { integrationStore } from '$lib/stores/integration';
|
||||
import { preferencesStore } from '$lib/stores/preferences';
|
||||
import { initCacheTTLs } from '$lib/stores/cacheTtl';
|
||||
import { playerStore } from '$lib/stores/player.svelte';
|
||||
import { launchYouTubePlayback } from '$lib/player/launchYouTubePlayback';
|
||||
@@ -40,6 +41,7 @@
|
||||
import { requestCountStore } from '$lib/stores/requestCountStore.svelte';
|
||||
import { nowPlayingMerged } from '$lib/stores/nowPlayingMerged.svelte';
|
||||
import { nowPlayingStore } from '$lib/stores/nowPlayingSessions.svelte';
|
||||
import { homeSettingsStore } from '$lib/stores/homeSettings.svelte';
|
||||
import SidebarVisualiser from '$lib/components/SidebarVisualiser.svelte';
|
||||
import { createNavigationProgressController } from '$lib/utils/navigationProgress';
|
||||
import { fromStore } from 'svelte/store';
|
||||
@@ -140,12 +142,24 @@
|
||||
deferInit(() => {
|
||||
libraryStore.initialize();
|
||||
void imageSettingsStore.load();
|
||||
// Pull saved user preferences (download_options, included
|
||||
// release types, etc.) so components like AlbumTrackList /
|
||||
// TrackRow see the actual saved values on first paint instead
|
||||
// of the all-true client defaults. Without this, the prefs
|
||||
// only loaded when the Settings page mounted, so any other
|
||||
// page rendered with stale defaults.
|
||||
void preferencesStore.load();
|
||||
void restorePlayerSession();
|
||||
void scrobbleManager.init();
|
||||
requestCountStore.startPolling();
|
||||
syncStatus.connect();
|
||||
});
|
||||
integrationStore.ensureLoaded().then(() => {
|
||||
// Load home settings before starting the now-playing poller. The
|
||||
// poller's fetchAll() gate keys on homeSettingsStore.showNowPlaying;
|
||||
// without this load, the first 3s tick can leak server sessions
|
||||
// against the default (which itself defaults False, but if it were
|
||||
// ever flipped True server-side we want the gate to reflect it).
|
||||
Promise.all([integrationStore.ensureLoaded(), homeSettingsStore.ensureLoaded()]).then(() => {
|
||||
nowPlayingStore.start();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
NavidromeAlbumMatch,
|
||||
NavidromeTrackInfo,
|
||||
PlexAlbumMatch,
|
||||
PlexTrackInfo
|
||||
PlexTrackInfo,
|
||||
TrackButtonVisibility
|
||||
} from '$lib/types';
|
||||
import { preferencesStore } from '$lib/stores/preferences';
|
||||
import type { MenuItem } from '$lib/components/ContextMenu.svelte';
|
||||
import type { RenderedTrackSection } from './albumTrackResolvers';
|
||||
import { resolveSourceTrack } from './albumTrackResolvers';
|
||||
@@ -23,6 +25,14 @@
|
||||
import TrackPlayButton from '$lib/components/TrackPlayButton.svelte';
|
||||
import TrackPreviewButton from '$lib/components/TrackPreviewButton.svelte';
|
||||
import TrackSourceButton from '$lib/components/TrackSourceButton.svelte';
|
||||
import TrackDownloadButton from '$lib/components/TrackDownloadButton.svelte';
|
||||
import LidarrRequestButton from '$lib/components/LidarrRequestButton.svelte';
|
||||
import {
|
||||
getLidarrRequestStatus,
|
||||
projectButtonStatus,
|
||||
buildStatusLookup,
|
||||
type LidarrButtonStatus
|
||||
} from '$lib/api/lidarrRequest';
|
||||
import ContextMenu from '$lib/components/ContextMenu.svelte';
|
||||
import JellyfinIcon from '$lib/components/JellyfinIcon.svelte';
|
||||
import LocalFilesIcon from '$lib/components/LocalFilesIcon.svelte';
|
||||
@@ -99,6 +109,80 @@
|
||||
onQuotaUpdate,
|
||||
getTrackContextMenuItems
|
||||
}: Props = $props();
|
||||
|
||||
// Lidarr per-track status. Re-fetched + polled on every album change.
|
||||
//
|
||||
// IMPORTANT: must use $effect (not onMount) for setup so the lookup
|
||||
// rebuilds when SvelteKit navigates between albums — SvelteKit reuses
|
||||
// this component across `/album/A` → `/album/B` transitions, so
|
||||
// onMount only fires once and a stale Album A lookup would leak into
|
||||
// Album B's rendering. The position+disc fallback would then collide
|
||||
// (positions 1,2,3… exist on every album) and every track on Album B
|
||||
// would falsely render as "requested in Lidarr".
|
||||
let statusLookup = $state<ReturnType<typeof buildStatusLookup> | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
// Reactive dep: re-run whenever the album mbid changes.
|
||||
const albumMbid = album?.musicbrainz_id;
|
||||
if (!albumMbid) {
|
||||
statusLookup = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset the lookup BEFORE the fetch lands so buttons default to
|
||||
// idle during the transition (better than briefly showing the
|
||||
// previous album's state).
|
||||
statusLookup = null;
|
||||
|
||||
let cancelled = false;
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await getLidarrRequestStatus(albumMbid);
|
||||
if (cancelled) return;
|
||||
statusLookup = buildStatusLookup(albumMbid, res);
|
||||
} catch {
|
||||
// Lidarr unreachable or album not in library — leave null.
|
||||
// Buttons fall back to `none` (idle) and remain clickable.
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
const handle = setInterval(refresh, 30000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(handle);
|
||||
};
|
||||
});
|
||||
|
||||
function statusFor(
|
||||
recordingId: string | null | undefined,
|
||||
position: number,
|
||||
disc: number
|
||||
): LidarrButtonStatus {
|
||||
if (!statusLookup) return 'none';
|
||||
const albumMbid = album?.musicbrainz_id;
|
||||
if (!albumMbid) return 'none';
|
||||
return projectButtonStatus(statusLookup.lookup(albumMbid, recordingId, position, disc));
|
||||
}
|
||||
|
||||
// User's per-button visibility prefs for the album-page slot. Each
|
||||
// existing per-button render-gate (showJellyfinBtn, youtubeEnabled,
|
||||
// etc.) is AND'd with the matching flag here — unchecked = force-off,
|
||||
// checked = the existing source-availability gate decides.
|
||||
let buttonVisibility = $state<TrackButtonVisibility>({
|
||||
lidarr_request: true,
|
||||
track_download: true,
|
||||
preview: true,
|
||||
yt_play: true,
|
||||
jellyfin: true,
|
||||
local_files: true,
|
||||
navidrome: true,
|
||||
plex: true
|
||||
});
|
||||
preferencesStore.subscribe((prefs) => {
|
||||
buttonVisibility = prefs.download_options.album_page;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="bg-base-200 rounded-box overflow-visible">
|
||||
@@ -151,22 +235,32 @@
|
||||
(playerStore.currentQueueItem?.discNumber ?? 1) === trackDiscNumber &&
|
||||
playerStore.currentQueueItem?.trackNumber === track.position &&
|
||||
playerStore.isPlaying}
|
||||
{@const showJellyfinBtn = jellyfinEnabled && jellyfinMatch?.found}
|
||||
{@const showLocalBtn = localfilesEnabled && localMatch?.found}
|
||||
{@const showNavidromeBtn = navidromeEnabled && navidromeMatch?.found}
|
||||
{@const showPlexBtn = plexEnabled && plexMatch?.found}
|
||||
{@const showJellyfinBtn = buttonVisibility.jellyfin && jellyfinEnabled && jellyfinMatch?.found}
|
||||
{@const showLocalBtn = buttonVisibility.local_files && localfilesEnabled && localMatch?.found}
|
||||
{@const showNavidromeBtn = buttonVisibility.navidrome && navidromeEnabled && navidromeMatch?.found}
|
||||
{@const showPlexBtn = buttonVisibility.plex && plexEnabled && plexMatch?.found}
|
||||
{@const hasAnySource =
|
||||
tl !== null ||
|
||||
jellyfinTrack !== null ||
|
||||
localTrack !== null ||
|
||||
navidromeTrack !== null ||
|
||||
plexTrack !== null}
|
||||
{@const showPreview = youtubeApiConfigured && !hasAnySource}
|
||||
{@const showPreview = buttonVisibility.preview && youtubeApiConfigured && !hasAnySource}
|
||||
{@const showYtPlay = buttonVisibility.yt_play && youtubeEnabled}
|
||||
{@const showLidarrRequest = buttonVisibility.lidarr_request && !!track.recording_id}
|
||||
{@const showTrackDownload = buttonVisibility.track_download}
|
||||
<li
|
||||
class="list-row group hover:bg-base-300/50 transition-colors p-3 sm:p-4"
|
||||
style={isCurrentlyPlaying ? `background-color: ${colors.accent}20;` : ''}
|
||||
>
|
||||
<div class="list-col-grow flex items-center gap-4 w-full">
|
||||
<!--
|
||||
flex-wrap so the action cluster (up to 9 buttons: preview /
|
||||
play / 4 source icons / lidarr-request / download / context-menu)
|
||||
drops to a second line on narrow mobile viewports instead of
|
||||
squeezing the track title to zero width via the cluster's
|
||||
shrink-0.
|
||||
-->
|
||||
<div class="list-col-grow flex flex-wrap items-center gap-x-4 gap-y-2 w-full">
|
||||
<div
|
||||
class="font-medium w-8 text-center shrink-0 {isCurrentlyPlaying
|
||||
? ''
|
||||
@@ -180,7 +274,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex-1 min-w-[10rem]">
|
||||
<div
|
||||
class="font-medium truncate"
|
||||
style={isCurrentlyPlaying ? `color: ${colors.accent};` : ''}
|
||||
@@ -193,8 +287,7 @@
|
||||
{formatDuration(track.length)}
|
||||
</div>
|
||||
|
||||
{#if youtubeEnabled || showPreview || showJellyfinBtn || showLocalBtn || showNavidromeBtn || showPlexBtn}
|
||||
<div class="flex items-center gap-1.5 shrink-0 ml-auto">
|
||||
<div class="flex flex-wrap items-center gap-1.5 sm:shrink-0 sm:ml-auto justify-end">
|
||||
{#if showPreview}
|
||||
<TrackPreviewButton
|
||||
artist={album.artist_name}
|
||||
@@ -209,7 +302,7 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if youtubeEnabled}
|
||||
{#if showYtPlay}
|
||||
<TrackPlayButton
|
||||
trackNumber={track.position}
|
||||
discNumber={trackDiscNumber}
|
||||
@@ -283,6 +376,29 @@
|
||||
</TrackSourceButton>
|
||||
{/if}
|
||||
|
||||
{#if showLidarrRequest}
|
||||
<LidarrRequestButton
|
||||
albumMbid={album.musicbrainz_id}
|
||||
trackMbid={track.recording_id}
|
||||
trackTitle={track.title}
|
||||
artistMbid={album.artist_id}
|
||||
trackPosition={track.position}
|
||||
discNumber={trackDiscNumber}
|
||||
initialStatus={statusFor(track.recording_id, track.position, trackDiscNumber)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showTrackDownload}
|
||||
<TrackDownloadButton
|
||||
artist={album.artist_name}
|
||||
album={album.title}
|
||||
trackTitle={track.title}
|
||||
artistMbid={album.artist_id}
|
||||
trackPosition={track.position}
|
||||
discNumber={trackDiscNumber}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<ContextMenu
|
||||
items={getTrackContextMenuItems(
|
||||
@@ -297,7 +413,6 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||