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,
|
StopReportRequest,
|
||||||
)
|
)
|
||||||
from core.dependencies import (
|
from core.dependencies import (
|
||||||
|
get_cache,
|
||||||
get_jellyfin_playback_service,
|
get_jellyfin_playback_service,
|
||||||
get_local_files_service,
|
get_local_files_service,
|
||||||
get_navidrome_playback_service,
|
get_navidrome_playback_service,
|
||||||
get_plex_playback_service,
|
get_plex_playback_service,
|
||||||
)
|
)
|
||||||
from core.exceptions import ExternalServiceError, PlaybackNotAllowedError, ResourceNotFoundError
|
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 infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute
|
||||||
from services.jellyfin_playback_service import JellyfinPlaybackService
|
from services.jellyfin_playback_service import JellyfinPlaybackService
|
||||||
from services.local_files_service import LocalFilesService
|
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"),
|
media_type=headers.get("Content-Type", "application/octet-stream"),
|
||||||
)
|
)
|
||||||
except ResourceNotFoundError:
|
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")
|
raise HTTPException(status_code=404, detail="Track file not found")
|
||||||
except FileNotFoundError:
|
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")
|
raise HTTPException(status_code=404, detail="Track file not found on disk")
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
raise HTTPException(status_code=403, detail="Access denied: path is outside the music directory")
|
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 = ""
|
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):
|
class UserPreferences(AppStruct):
|
||||||
primary_types: list[str] = msgspec.field(default_factory=lambda: ["album", "ep", "single"])
|
primary_types: list[str] = msgspec.field(default_factory=lambda: ["album", "ep", "single"])
|
||||||
secondary_types: list[str] = msgspec.field(default_factory=lambda: ["studio"])
|
secondary_types: list[str] = msgspec.field(default_factory=lambda: ["studio"])
|
||||||
release_statuses: list[str] = msgspec.field(default_factory=lambda: ["official"])
|
release_statuses: list[str] = msgspec.field(default_factory=lambda: ["official"])
|
||||||
|
download_options: DownloadOptions = msgspec.field(default_factory=DownloadOptions)
|
||||||
|
|
||||||
|
|
||||||
class LidarrConnectionSettings(AppStruct):
|
class LidarrConnectionSettings(AppStruct):
|
||||||
@@ -171,18 +206,26 @@ class HomeSettings(AppStruct):
|
|||||||
cache_ttl_personal: int = 300
|
cache_ttl_personal: int = 300
|
||||||
show_whats_hot: bool = True
|
show_whats_hot: bool = True
|
||||||
show_globally_trending: bool = True
|
show_globally_trending: bool = True
|
||||||
|
# Defaults False because Plex /status/sessions returns ALL active audio
|
||||||
def __post_init__(self) -> None:
|
# streams across the whole server with no library-section filter — on a
|
||||||
if self.cache_ttl_trending < 300 or self.cache_ttl_trending > 86400:
|
# shared instance that means anyone hitting the UI sees what every other
|
||||||
raise msgspec.ValidationError("cache_ttl_trending must be between 300 and 86400")
|
# household member is listening to. Local MusicSeerr playback (the user's
|
||||||
if self.cache_ttl_personal < 60 or self.cache_ttl_personal > 3600:
|
# own tab) is unaffected; this only gates the server-derived feed used
|
||||||
raise msgspec.ValidationError("cache_ttl_personal must be between 60 and 3600")
|
# by HomeSectionNowPlaying, SidebarVisualiser, and the /library/* pages.
|
||||||
|
show_now_playing: bool = False
|
||||||
|
|
||||||
|
|
||||||
class LocalFilesConnectionSettings(AppStruct):
|
class LocalFilesConnectionSettings(AppStruct):
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
music_path: str = "/music"
|
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):
|
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
|
||||||
@@ -66,6 +66,52 @@ class Settings(BaseSettings):
|
|||||||
audiodb_api_key: str = Field(default="123")
|
audiodb_api_key: str = Field(default="123")
|
||||||
audiodb_premium: bool = Field(default=False, description="Set to true if using a premium AudioDB API key")
|
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")
|
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")
|
@field_validator("log_level")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ from .service_providers import ( # noqa: F401
|
|||||||
get_plex_library_service,
|
get_plex_library_service,
|
||||||
get_plex_playback_service,
|
get_plex_playback_service,
|
||||||
get_version_service,
|
get_version_service,
|
||||||
|
get_track_download_service,
|
||||||
|
get_lidarr_request_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .type_aliases import ( # noqa: F401
|
from .type_aliases import ( # noqa: F401
|
||||||
@@ -120,6 +122,7 @@ from .type_aliases import ( # noqa: F401
|
|||||||
CacheStatusServiceDep,
|
CacheStatusServiceDep,
|
||||||
GitHubRepositoryDep,
|
GitHubRepositoryDep,
|
||||||
VersionServiceDep,
|
VersionServiceDep,
|
||||||
|
TrackDownloadServiceDep,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .cleanup import ( # noqa: F401
|
from .cleanup import ( # noqa: F401
|
||||||
|
|||||||
@@ -657,3 +657,27 @@ def get_version_service() -> "VersionService":
|
|||||||
|
|
||||||
github_repo = get_github_repository()
|
github_repo = get_github_repository()
|
||||||
return VersionService(github_repo)
|
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.scrobble_service import ScrobbleService
|
||||||
from services.cache_status_service import CacheStatusService
|
from services.cache_status_service import CacheStatusService
|
||||||
from services.version_service import VersionService
|
from services.version_service import VersionService
|
||||||
|
from services.track_download_service import TrackDownloadService
|
||||||
|
|
||||||
from .cache_providers import (
|
from .cache_providers import (
|
||||||
get_cache,
|
get_cache,
|
||||||
@@ -105,6 +106,7 @@ from .service_providers import (
|
|||||||
get_plex_library_service,
|
get_plex_library_service,
|
||||||
get_plex_playback_service,
|
get_plex_playback_service,
|
||||||
get_version_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)]
|
CacheStatusServiceDep = Annotated[CacheStatusService, Depends(get_cache_status_service)]
|
||||||
GitHubRepositoryDep = Annotated[GitHubRepository, Depends(get_github_repository)]
|
GitHubRepositoryDep = Annotated[GitHubRepository, Depends(get_github_repository)]
|
||||||
VersionServiceDep = Annotated[VersionService, Depends(get_version_service)]
|
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 plex_auth as plex_auth_routes
|
||||||
from api.v1.routes import version as version_routes
|
from api.v1.routes import version as version_routes
|
||||||
from api.v1.routes import download as download_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')
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -289,6 +291,9 @@ app.add_middleware(
|
|||||||
"/api/v1/search": (10.0, 20),
|
"/api/v1/search": (10.0, 20),
|
||||||
"/api/v1/discover": (10.0, 20),
|
"/api/v1/discover": (10.0, 20),
|
||||||
"/api/v1/covers": (15.0, 30),
|
"/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)
|
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(playlists.router)
|
||||||
v1_router.include_router(version_routes.router)
|
v1_router.include_router(version_routes.router)
|
||||||
v1_router.include_router(download_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)
|
app.include_router(v1_router)
|
||||||
|
|
||||||
mount_frontend(app)
|
mount_frontend(app)
|
||||||
|
|||||||
@@ -315,6 +315,109 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
|
|||||||
logger.error(f"Failed to delete album {album_id}: {e}")
|
logger.error(f"Failed to delete album {album_id}: {e}")
|
||||||
raise
|
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:
|
async def set_monitored(self, album_mbid: str, monitored: bool) -> bool:
|
||||||
lidarr_album = await self._get_album_by_foreign_id(album_mbid)
|
lidarr_album = await self._get_album_by_foreign_id(album_mbid)
|
||||||
if not lidarr_album:
|
if not lidarr_album:
|
||||||
@@ -329,7 +432,20 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
|
|||||||
await self._invalidate_album_list_caches()
|
await self._invalidate_album_list_caches()
|
||||||
return True
|
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()
|
t0 = time.monotonic()
|
||||||
if not musicbrainz_id or not isinstance(musicbrainz_id, str):
|
if not musicbrainz_id or not isinstance(musicbrainz_id, str):
|
||||||
raise ExternalServiceError("Invalid MBID provided")
|
raise ExternalServiceError("Invalid MBID provided")
|
||||||
@@ -361,6 +477,7 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
|
|||||||
musicbrainz_id, artist_repo, t0,
|
musicbrainz_id, artist_repo, t0,
|
||||||
candidate, album_title, album_type, secondary_types,
|
candidate, album_title, album_type, secondary_types,
|
||||||
artist_mbid, artist_name,
|
artist_mbid, artist_name,
|
||||||
|
search_after_add=search_after_add,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _add_album_locked(
|
async def _add_album_locked(
|
||||||
@@ -374,6 +491,7 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
|
|||||||
secondary_types: list,
|
secondary_types: list,
|
||||||
artist_mbid: str,
|
artist_mbid: str,
|
||||||
artist_name: str | None,
|
artist_name: str | None,
|
||||||
|
search_after_add: bool = True,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
# Capture which albums are already monitored so we can revert any Lidarr auto-monitors after the add
|
# Capture which albums are already monitored so we can revert any Lidarr auto-monitors after the add
|
||||||
pre_add_monitored_ids: set[int] = set()
|
pre_add_monitored_ids: set[int] = set()
|
||||||
@@ -414,10 +532,11 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
|
|||||||
if not is_monitored:
|
if not is_monitored:
|
||||||
album_obj = await self._update_album(album_id, {"monitored": True})
|
album_obj = await self._update_album(album_id, {"monitored": True})
|
||||||
|
|
||||||
try:
|
if search_after_add:
|
||||||
await self._post_command({"name": "AlbumSearch", "albumIds": [album_id]})
|
try:
|
||||||
except ExternalServiceError:
|
await self._post_command({"name": "AlbumSearch", "albumIds": [album_id]})
|
||||||
pass
|
except ExternalServiceError:
|
||||||
|
pass
|
||||||
|
|
||||||
await self._unmonitor_auto_monitored_albums(
|
await self._unmonitor_auto_monitored_albums(
|
||||||
artist_id, musicbrainz_id, album_id, pre_add_monitored_ids
|
artist_id, musicbrainz_id, album_id, pre_add_monitored_ids
|
||||||
@@ -474,7 +593,7 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
|
|||||||
"anyReleaseOk": True,
|
"anyReleaseOk": True,
|
||||||
"profileId": profile_id,
|
"profileId": profile_id,
|
||||||
"images": [],
|
"images": [],
|
||||||
"addOptions": {"addType": "automatic", "searchForNewAlbum": True},
|
"addOptions": {"addType": "automatic", "searchForNewAlbum": search_after_add},
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -487,12 +606,13 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
|
|||||||
if album_obj:
|
if album_obj:
|
||||||
if not album_obj.get("monitored"):
|
if not album_obj.get("monitored"):
|
||||||
album_obj = await self._update_album(album_obj["id"], {"monitored": True})
|
album_obj = await self._update_album(album_obj["id"], {"monitored": True})
|
||||||
try:
|
if search_after_add:
|
||||||
await self._post_command(
|
try:
|
||||||
{"name": "AlbumSearch", "albumIds": [album_obj["id"]]}
|
await self._post_command(
|
||||||
)
|
{"name": "AlbumSearch", "albumIds": [album_obj["id"]]}
|
||||||
except ExternalServiceError:
|
)
|
||||||
pass
|
except ExternalServiceError:
|
||||||
|
pass
|
||||||
elif "post failed" in err_str or "405" in err_str or "metadata" in err_str:
|
elif "post failed" in err_str or "405" in err_str or "metadata" in err_str:
|
||||||
raise ExternalServiceError(
|
raise ExternalServiceError(
|
||||||
f"Lidarr rejected '{album_title}' ({album_type}"
|
f"Lidarr rejected '{album_title}' ({album_type}"
|
||||||
@@ -519,10 +639,11 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
|
|||||||
set_artist_monitored=artist_created,
|
set_artist_monitored=artist_created,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
if search_after_add:
|
||||||
await self._post_command({"name": "AlbumSearch", "albumIds": [album_id]})
|
try:
|
||||||
except ExternalServiceError:
|
await self._post_command({"name": "AlbumSearch", "albumIds": [album_id]})
|
||||||
pass
|
except ExternalServiceError:
|
||||||
|
pass
|
||||||
|
|
||||||
# Unmonitor albums that Lidarr auto-monitored during the add
|
# Unmonitor albums that Lidarr auto-monitored during the add
|
||||||
await self._unmonitor_auto_monitored_albums(
|
await self._unmonitor_auto_monitored_albums(
|
||||||
|
|||||||
@@ -192,6 +192,37 @@ class LidarrArtistRepository(LidarrBase):
|
|||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
return None
|
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:
|
async def delete_artist(self, artist_id: int, delete_files: bool = False) -> bool:
|
||||||
try:
|
try:
|
||||||
params = {"deleteFiles": str(delete_files).lower(), "addImportListExclusion": "false"}
|
params = {"deleteFiles": str(delete_files).lower(), "addImportListExclusion": "false"}
|
||||||
|
|||||||
@@ -32,5 +32,7 @@ class LidarrRepository(
|
|||||||
super().__init__(settings, http_client, cache)
|
super().__init__(settings, http_client, cache)
|
||||||
self._request_history_store = request_history_store
|
self._request_history_store = request_history_store
|
||||||
|
|
||||||
async def add_album(self, musicbrainz_id: str) -> dict:
|
async def add_album(self, musicbrainz_id: str, search_after_add: bool = True) -> dict:
|
||||||
return await LidarrAlbumRepository.add_album(self, musicbrainz_id, self)
|
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
|
except Exception: # noqa: BLE001
|
||||||
logger.debug("Jellyfin track resolution failed for %s", album_mbid, exc_info=True)
|
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
|
return result
|
||||||
|
|
||||||
async def resolve_tracks_batch(
|
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:
|
class TestSingletonRegistry:
|
||||||
def test_registry_has_expected_count(self):
|
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):
|
def test_all_entries_have_cache_clear(self):
|
||||||
for fn in _singleton_registry:
|
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,
|
cache_ttl_local_files_storage_stats=300,
|
||||||
)
|
)
|
||||||
prefs.get_local_files_connection.return_value = MagicMock(
|
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()
|
cache = cache or AsyncMock()
|
||||||
return LocalFilesService(
|
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/constants';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import { playerStore } from '$lib/stores/player.svelte';
|
import { playerStore } from '$lib/stores/player.svelte';
|
||||||
|
import { libraryRefresh } from '$lib/stores/libraryRefresh.svelte';
|
||||||
import TrackRow from './TrackRow.svelte';
|
import TrackRow from './TrackRow.svelte';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
import {
|
||||||
|
getLidarrRequestStatus,
|
||||||
|
projectButtonStatus,
|
||||||
|
type LidarrButtonStatus
|
||||||
|
} from '$lib/api/lidarrRequest';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
songs: TopSong[];
|
songs: TopSong[];
|
||||||
@@ -25,8 +31,16 @@
|
|||||||
|
|
||||||
let cacheMap = new SvelteMap<string, boolean>();
|
let cacheMap = new SvelteMap<string, boolean>();
|
||||||
let resolveMap = new SvelteMap<string, ResolvedTrack>();
|
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 lastFetchedKey = $state('');
|
||||||
let lastResolveKey = $state('');
|
let lastResolveKey = $state('');
|
||||||
|
let lastLidarrStatusKey = $state('');
|
||||||
|
|
||||||
function cacheKey(artist: string, track: string): string {
|
function cacheKey(artist: string, track: string): string {
|
||||||
return `${artist.toLowerCase()}|${track.toLowerCase()}`;
|
return `${artist.toLowerCase()}|${track.toLowerCase()}`;
|
||||||
@@ -49,7 +63,10 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!ytConfigured || songs.length === 0) return;
|
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;
|
if (key === lastFetchedKey) return;
|
||||||
lastFetchedKey = key;
|
lastFetchedKey = key;
|
||||||
|
|
||||||
@@ -75,7 +92,9 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const resolvable = songs.filter((s) => s.release_group_mbid && s.track_number != null);
|
const resolvable = songs.filter((s) => s.release_group_mbid && s.track_number != null);
|
||||||
if (resolvable.length === 0) return;
|
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;
|
if (key === lastResolveKey) return;
|
||||||
lastResolveKey = key;
|
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 } {
|
function buildQueueItems(startSong: TopSong): { items: QueueItem[]; startIndex: number } {
|
||||||
const items: QueueItem[] = [];
|
const items: QueueItem[] = [];
|
||||||
let startIndex = 0;
|
let startIndex = 0;
|
||||||
@@ -195,6 +252,7 @@
|
|||||||
{ytConfigured}
|
{ytConfigured}
|
||||||
initialCached={cacheMap.get(cacheKey(song.artist_name, song.title)) ?? null}
|
initialCached={cacheMap.get(cacheKey(song.artist_name, song.title)) ?? null}
|
||||||
resolvedTrack={getResolvedTrack(song)}
|
resolvedTrack={getResolvedTrack(song)}
|
||||||
|
lidarrStatus={lidarrStatusForSong(song)}
|
||||||
onPlay={() => handlePlay(song)}
|
onPlay={() => handlePlay(song)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/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">
|
<script lang="ts">
|
||||||
import { albumHref } from '$lib/utils/entityRoutes';
|
import { albumHref } from '$lib/utils/entityRoutes';
|
||||||
import { Play, Disc3 } from 'lucide-svelte';
|
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 AlbumImage from './AlbumImage.svelte';
|
||||||
import LastFmPlaceholder from './LastFmPlaceholder.svelte';
|
import LastFmPlaceholder from './LastFmPlaceholder.svelte';
|
||||||
import TrackPreviewButton from './TrackPreviewButton.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 {
|
interface Props {
|
||||||
song: TopSong;
|
song: TopSong;
|
||||||
@@ -14,6 +18,7 @@
|
|||||||
ytConfigured?: boolean;
|
ytConfigured?: boolean;
|
||||||
initialCached?: boolean | null;
|
initialCached?: boolean | null;
|
||||||
resolvedTrack?: ResolvedTrack | null;
|
resolvedTrack?: ResolvedTrack | null;
|
||||||
|
lidarrStatus?: LidarrButtonStatus;
|
||||||
onPlay?: () => void;
|
onPlay?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +30,7 @@
|
|||||||
ytConfigured = false,
|
ytConfigured = false,
|
||||||
initialCached = null,
|
initialCached = null,
|
||||||
resolvedTrack = null,
|
resolvedTrack = null,
|
||||||
|
lidarrStatus = 'none',
|
||||||
onPlay
|
onPlay
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -32,6 +38,28 @@
|
|||||||
let isLastfmNoAlbum = $derived(!hasAlbum && source === 'lastfm');
|
let isLastfmNoAlbum = $derived(!hasAlbum && source === 'lastfm');
|
||||||
let canPlay = $derived(!!resolvedTrack?.source);
|
let canPlay = $derived(!!resolvedTrack?.source);
|
||||||
let previewEnabled = $derived(showPreview && ytConfigured && !canPlay);
|
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>
|
</script>
|
||||||
|
|
||||||
{#if hasAlbum}
|
{#if hasAlbum}
|
||||||
@@ -39,13 +67,11 @@
|
|||||||
{#if canPlay}
|
{#if canPlay}
|
||||||
<button
|
<button
|
||||||
onclick={onPlay}
|
onclick={onPlay}
|
||||||
class="w-6 shrink-0 flex items-center justify-center cursor-pointer"
|
class="w-6 shrink-0 flex items-center justify-center cursor-pointer text-primary"
|
||||||
aria-label="Play {song.title}"
|
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>
|
<Play class="w-4 h-4 mx-auto fill-current" />
|
||||||
<span class="hidden group-hover:block text-primary">
|
|
||||||
<Play class="w-4 h-4 mx-auto fill-current" />
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
{:else if previewEnabled}
|
{:else if previewEnabled}
|
||||||
<span class="w-6 shrink-0 flex items-center justify-center">
|
<span class="w-6 shrink-0 flex items-center justify-center">
|
||||||
@@ -93,6 +119,40 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
@@ -103,13 +163,11 @@
|
|||||||
{#if canPlay}
|
{#if canPlay}
|
||||||
<button
|
<button
|
||||||
onclick={onPlay}
|
onclick={onPlay}
|
||||||
class="w-6 shrink-0 flex items-center justify-center cursor-pointer"
|
class="w-6 shrink-0 flex items-center justify-center cursor-pointer text-primary"
|
||||||
aria-label="Play {song.title}"
|
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>
|
<Play class="w-4 h-4 mx-auto fill-current" />
|
||||||
<span class="hidden group-hover:block text-primary">
|
|
||||||
<Play class="w-4 h-4 mx-auto fill-current" />
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
{:else if previewEnabled}
|
{:else if previewEnabled}
|
||||||
<span class="w-6 shrink-0 flex items-center justify-center">
|
<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="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>
|
<p class="text-xs text-base-content/40 truncate min-w-0 text-right italic"></p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/if}
|
{/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 { createSettingsForm } from '$lib/utils/settingsForm.svelte';
|
||||||
import { invalidateQueriesWithPersister } from '$lib/queries/QueryClient';
|
import { invalidateQueriesWithPersister } from '$lib/queries/QueryClient';
|
||||||
import { HomeQueryKeyFactory } from '$lib/queries/HomeQueryKeyFactory';
|
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';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
|
||||||
const form = createSettingsForm<HomeSettings>({
|
const form = createSettingsForm<HomeSettings>({
|
||||||
@@ -10,6 +12,12 @@
|
|||||||
saveEndpoint: '/api/v1/settings/home',
|
saveEndpoint: '/api/v1/settings/home',
|
||||||
afterSave: async () => {
|
afterSave: async () => {
|
||||||
await invalidateQueriesWithPersister({ queryKey: HomeQueryKeyFactory.prefix });
|
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>
|
</label>
|
||||||
</div>
|
</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}
|
{#if form.message}
|
||||||
<div
|
<div
|
||||||
class="alert"
|
class="alert"
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ vi.mock('$env/dynamic/public', () => ({
|
|||||||
|
|
||||||
import SettingsHome from './SettingsHome.svelte';
|
import SettingsHome from './SettingsHome.svelte';
|
||||||
|
|
||||||
function mockHomeSettings(overrides: { show_whats_hot?: boolean } = {}) {
|
function mockHomeSettings(
|
||||||
|
overrides: { show_whats_hot?: boolean; show_now_playing?: boolean } = {}
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
show_whats_hot: true,
|
show_whats_hot: true,
|
||||||
show_globally_trending: true,
|
show_globally_trending: true,
|
||||||
|
show_now_playing: false,
|
||||||
cache_ttl_trending: 3600,
|
cache_ttl_trending: 3600,
|
||||||
cache_ttl_personal: 300,
|
cache_ttl_personal: 300,
|
||||||
...overrides
|
...overrides
|
||||||
@@ -110,4 +113,38 @@ describe('SettingsHome.svelte', () => {
|
|||||||
expect(toggle.checked).toBe(true);
|
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,
|
UserPreferences,
|
||||||
ReleaseTypeOption,
|
ReleaseTypeOption,
|
||||||
LidarrMetadataProfilePreferences,
|
LidarrMetadataProfilePreferences,
|
||||||
MetadataProfile
|
MetadataProfile,
|
||||||
|
TrackButtonKey,
|
||||||
|
DownloadOptionsContext
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
import { invalidateQueriesWithPersister } from '$lib/queries/QueryClient';
|
import { invalidateQueriesWithPersister } from '$lib/queries/QueryClient';
|
||||||
import { ArtistQueryKeyFactory } from '$lib/queries/artist/ArtistQueryKeyFactory';
|
import { ArtistQueryKeyFactory } from '$lib/queries/artist/ArtistQueryKeyFactory';
|
||||||
@@ -14,7 +16,31 @@
|
|||||||
let preferences: UserPreferences = $state({
|
let preferences: UserPreferences = $state({
|
||||||
primary_types: [],
|
primary_types: [],
|
||||||
secondary_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 saving = $state(false);
|
||||||
let saveMessage = $state('');
|
let saveMessage = $state('');
|
||||||
@@ -62,6 +88,94 @@
|
|||||||
{ id: 'pseudo-release', title: 'Pseudo-Release', description: 'Placeholder or meta releases' }
|
{ 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(
|
function toggleType(
|
||||||
category: 'primary_types' | 'secondary_types' | 'release_statuses',
|
category: 'primary_types' | 'secondary_types' | 'release_statuses',
|
||||||
id: string
|
id: string
|
||||||
@@ -308,6 +422,46 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/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 bg-base-200">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl mb-4">Included Releases</h2>
|
<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>
|
<h3 class="text-xl font-semibold mb-4">Release Statuses</h3>
|
||||||
{@render typeTable(releaseStatuses, 'release_statuses')}
|
{@render typeTable(releaseStatuses, 'release_statuses')}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end items-center gap-4">
|
<div class="card bg-base-200 mt-6">
|
||||||
{#if saveMessage}
|
<div class="card-body">
|
||||||
<div
|
<h2 class="card-title text-2xl mb-4">Download Options</h2>
|
||||||
class="alert flex-1"
|
<p class="text-base-content/70 mb-6">
|
||||||
class:alert-success={saveMessage.includes('success')}
|
Choose which <em>download</em> buttons appear next to each track. Direct download already
|
||||||
class:alert-error={saveMessage.includes('Failed')}
|
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.
|
||||||
<span>{saveMessage}</span>
|
</p>
|
||||||
</div>
|
|
||||||
{/if}
|
<div class="mb-8">
|
||||||
<button class="btn btn-primary" onclick={handleSave} disabled={saving}>
|
<h3 class="text-xl font-semibold mb-4">Album Track List</h3>
|
||||||
{#if saving}
|
{@render trackButtonTable(downloadButtons, 'album_page')}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
</div>
|
||||||
Saving...
|
|
||||||
{:else}
|
<div class="mb-8">
|
||||||
Save Settings
|
<h3 class="text-xl font-semibold mb-4">Popular Songs</h3>
|
||||||
{/if}
|
{@render trackButtonTable(downloadButtons, 'popular_songs')}
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 { API } from '$lib/constants';
|
||||||
import { integrationStore } from '$lib/stores/integration';
|
import { integrationStore } from '$lib/stores/integration';
|
||||||
|
import { homeSettingsStore } from '$lib/stores/homeSettings.svelte';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import type {
|
import type {
|
||||||
@@ -113,6 +114,17 @@ function createNowPlayingStore() {
|
|||||||
async function fetchAll() {
|
async function fetchAll() {
|
||||||
if (typeof document !== 'undefined' && document.hidden) return;
|
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 integrations = get(integrationStore);
|
||||||
const fetches: Promise<{
|
const fetches: Promise<{
|
||||||
source: SourceKey;
|
source: SourceKey;
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import type { UserPreferences } from '$lib/types';
|
import type { UserPreferences, TrackButtonVisibility } from '$lib/types';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
|
|
||||||
const API_BASE = '/api/v1';
|
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 = {
|
const defaultPreferences: UserPreferences = {
|
||||||
primary_types: ['album', 'ep', 'single'],
|
primary_types: ['album', 'ep', 'single'],
|
||||||
secondary_types: ['studio'],
|
secondary_types: ['studio'],
|
||||||
release_statuses: ['official']
|
release_statuses: ['official'],
|
||||||
|
download_options: {
|
||||||
|
popular_songs: { ...allVisible },
|
||||||
|
album_page: { ...allVisible }
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { subscribe, set, update } = writable<UserPreferences>(defaultPreferences);
|
const { subscribe, set, update } = writable<UserPreferences>(defaultPreferences);
|
||||||
|
|||||||
@@ -173,10 +173,34 @@ export type ArtistReleases = {
|
|||||||
source_total_count: number | null;
|
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 = {
|
export type UserPreferences = {
|
||||||
primary_types: string[];
|
primary_types: string[];
|
||||||
secondary_types: string[];
|
secondary_types: string[];
|
||||||
release_statuses: string[];
|
release_statuses: string[];
|
||||||
|
download_options: DownloadOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReleaseTypeOption = {
|
export type ReleaseTypeOption = {
|
||||||
@@ -273,6 +297,7 @@ export type HomeSettings = {
|
|||||||
cache_ttl_personal: number;
|
cache_ttl_personal: number;
|
||||||
show_whats_hot: boolean;
|
show_whats_hot: boolean;
|
||||||
show_globally_trending: boolean;
|
show_globally_trending: boolean;
|
||||||
|
show_now_playing: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HomeArtist = {
|
export type HomeArtist = {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { errorModal } from '$lib/stores/errorModal';
|
import { errorModal } from '$lib/stores/errorModal';
|
||||||
import { libraryStore } from '$lib/stores/library';
|
import { libraryStore } from '$lib/stores/library';
|
||||||
import { integrationStore } from '$lib/stores/integration';
|
import { integrationStore } from '$lib/stores/integration';
|
||||||
|
import { preferencesStore } from '$lib/stores/preferences';
|
||||||
import { initCacheTTLs } from '$lib/stores/cacheTtl';
|
import { initCacheTTLs } from '$lib/stores/cacheTtl';
|
||||||
import { playerStore } from '$lib/stores/player.svelte';
|
import { playerStore } from '$lib/stores/player.svelte';
|
||||||
import { launchYouTubePlayback } from '$lib/player/launchYouTubePlayback';
|
import { launchYouTubePlayback } from '$lib/player/launchYouTubePlayback';
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
import { requestCountStore } from '$lib/stores/requestCountStore.svelte';
|
import { requestCountStore } from '$lib/stores/requestCountStore.svelte';
|
||||||
import { nowPlayingMerged } from '$lib/stores/nowPlayingMerged.svelte';
|
import { nowPlayingMerged } from '$lib/stores/nowPlayingMerged.svelte';
|
||||||
import { nowPlayingStore } from '$lib/stores/nowPlayingSessions.svelte';
|
import { nowPlayingStore } from '$lib/stores/nowPlayingSessions.svelte';
|
||||||
|
import { homeSettingsStore } from '$lib/stores/homeSettings.svelte';
|
||||||
import SidebarVisualiser from '$lib/components/SidebarVisualiser.svelte';
|
import SidebarVisualiser from '$lib/components/SidebarVisualiser.svelte';
|
||||||
import { createNavigationProgressController } from '$lib/utils/navigationProgress';
|
import { createNavigationProgressController } from '$lib/utils/navigationProgress';
|
||||||
import { fromStore } from 'svelte/store';
|
import { fromStore } from 'svelte/store';
|
||||||
@@ -140,12 +142,24 @@
|
|||||||
deferInit(() => {
|
deferInit(() => {
|
||||||
libraryStore.initialize();
|
libraryStore.initialize();
|
||||||
void imageSettingsStore.load();
|
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 restorePlayerSession();
|
||||||
void scrobbleManager.init();
|
void scrobbleManager.init();
|
||||||
requestCountStore.startPolling();
|
requestCountStore.startPolling();
|
||||||
syncStatus.connect();
|
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();
|
nowPlayingStore.start();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,8 +10,10 @@
|
|||||||
NavidromeAlbumMatch,
|
NavidromeAlbumMatch,
|
||||||
NavidromeTrackInfo,
|
NavidromeTrackInfo,
|
||||||
PlexAlbumMatch,
|
PlexAlbumMatch,
|
||||||
PlexTrackInfo
|
PlexTrackInfo,
|
||||||
|
TrackButtonVisibility
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
|
import { preferencesStore } from '$lib/stores/preferences';
|
||||||
import type { MenuItem } from '$lib/components/ContextMenu.svelte';
|
import type { MenuItem } from '$lib/components/ContextMenu.svelte';
|
||||||
import type { RenderedTrackSection } from './albumTrackResolvers';
|
import type { RenderedTrackSection } from './albumTrackResolvers';
|
||||||
import { resolveSourceTrack } from './albumTrackResolvers';
|
import { resolveSourceTrack } from './albumTrackResolvers';
|
||||||
@@ -23,6 +25,14 @@
|
|||||||
import TrackPlayButton from '$lib/components/TrackPlayButton.svelte';
|
import TrackPlayButton from '$lib/components/TrackPlayButton.svelte';
|
||||||
import TrackPreviewButton from '$lib/components/TrackPreviewButton.svelte';
|
import TrackPreviewButton from '$lib/components/TrackPreviewButton.svelte';
|
||||||
import TrackSourceButton from '$lib/components/TrackSourceButton.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 ContextMenu from '$lib/components/ContextMenu.svelte';
|
||||||
import JellyfinIcon from '$lib/components/JellyfinIcon.svelte';
|
import JellyfinIcon from '$lib/components/JellyfinIcon.svelte';
|
||||||
import LocalFilesIcon from '$lib/components/LocalFilesIcon.svelte';
|
import LocalFilesIcon from '$lib/components/LocalFilesIcon.svelte';
|
||||||
@@ -99,6 +109,80 @@
|
|||||||
onQuotaUpdate,
|
onQuotaUpdate,
|
||||||
getTrackContextMenuItems
|
getTrackContextMenuItems
|
||||||
}: Props = $props();
|
}: 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>
|
</script>
|
||||||
|
|
||||||
<div class="bg-base-200 rounded-box overflow-visible">
|
<div class="bg-base-200 rounded-box overflow-visible">
|
||||||
@@ -151,22 +235,32 @@
|
|||||||
(playerStore.currentQueueItem?.discNumber ?? 1) === trackDiscNumber &&
|
(playerStore.currentQueueItem?.discNumber ?? 1) === trackDiscNumber &&
|
||||||
playerStore.currentQueueItem?.trackNumber === track.position &&
|
playerStore.currentQueueItem?.trackNumber === track.position &&
|
||||||
playerStore.isPlaying}
|
playerStore.isPlaying}
|
||||||
{@const showJellyfinBtn = jellyfinEnabled && jellyfinMatch?.found}
|
{@const showJellyfinBtn = buttonVisibility.jellyfin && jellyfinEnabled && jellyfinMatch?.found}
|
||||||
{@const showLocalBtn = localfilesEnabled && localMatch?.found}
|
{@const showLocalBtn = buttonVisibility.local_files && localfilesEnabled && localMatch?.found}
|
||||||
{@const showNavidromeBtn = navidromeEnabled && navidromeMatch?.found}
|
{@const showNavidromeBtn = buttonVisibility.navidrome && navidromeEnabled && navidromeMatch?.found}
|
||||||
{@const showPlexBtn = plexEnabled && plexMatch?.found}
|
{@const showPlexBtn = buttonVisibility.plex && plexEnabled && plexMatch?.found}
|
||||||
{@const hasAnySource =
|
{@const hasAnySource =
|
||||||
tl !== null ||
|
tl !== null ||
|
||||||
jellyfinTrack !== null ||
|
jellyfinTrack !== null ||
|
||||||
localTrack !== null ||
|
localTrack !== null ||
|
||||||
navidromeTrack !== null ||
|
navidromeTrack !== null ||
|
||||||
plexTrack !== 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
|
<li
|
||||||
class="list-row group hover:bg-base-300/50 transition-colors p-3 sm:p-4"
|
class="list-row group hover:bg-base-300/50 transition-colors p-3 sm:p-4"
|
||||||
style={isCurrentlyPlaying ? `background-color: ${colors.accent}20;` : ''}
|
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
|
<div
|
||||||
class="font-medium w-8 text-center shrink-0 {isCurrentlyPlaying
|
class="font-medium w-8 text-center shrink-0 {isCurrentlyPlaying
|
||||||
? ''
|
? ''
|
||||||
@@ -180,7 +274,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-[10rem]">
|
||||||
<div
|
<div
|
||||||
class="font-medium truncate"
|
class="font-medium truncate"
|
||||||
style={isCurrentlyPlaying ? `color: ${colors.accent};` : ''}
|
style={isCurrentlyPlaying ? `color: ${colors.accent};` : ''}
|
||||||
@@ -193,8 +287,7 @@
|
|||||||
{formatDuration(track.length)}
|
{formatDuration(track.length)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if youtubeEnabled || showPreview || showJellyfinBtn || showLocalBtn || showNavidromeBtn || showPlexBtn}
|
<div class="flex flex-wrap items-center gap-1.5 sm:shrink-0 sm:ml-auto justify-end">
|
||||||
<div class="flex items-center gap-1.5 shrink-0 ml-auto">
|
|
||||||
{#if showPreview}
|
{#if showPreview}
|
||||||
<TrackPreviewButton
|
<TrackPreviewButton
|
||||||
artist={album.artist_name}
|
artist={album.artist_name}
|
||||||
@@ -209,7 +302,7 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if youtubeEnabled}
|
{#if showYtPlay}
|
||||||
<TrackPlayButton
|
<TrackPlayButton
|
||||||
trackNumber={track.position}
|
trackNumber={track.position}
|
||||||
discNumber={trackDiscNumber}
|
discNumber={trackDiscNumber}
|
||||||
@@ -283,6 +376,29 @@
|
|||||||
</TrackSourceButton>
|
</TrackSourceButton>
|
||||||
{/if}
|
{/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>
|
<div>
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
items={getTrackContextMenuItems(
|
items={getTrackContextMenuItems(
|
||||||
@@ -297,7 +413,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||