23c9125ad8
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).
315 lines
9.4 KiB
Python
315 lines
9.4 KiB
Python
from typing import Literal
|
|
|
|
import msgspec
|
|
|
|
from api.v1.schemas.plex import PlexLibrarySectionInfo
|
|
from infrastructure.msgspec_fastapi import AppStruct
|
|
|
|
LASTFM_SECRET_MASK = "••••••••"
|
|
|
|
|
|
def _mask_secret(value: str) -> str:
|
|
if not value:
|
|
return ""
|
|
if len(value) <= 4:
|
|
return LASTFM_SECRET_MASK
|
|
return LASTFM_SECRET_MASK + value[-4:]
|
|
|
|
|
|
class LastFmConnectionSettings(AppStruct):
|
|
api_key: str = ""
|
|
shared_secret: str = ""
|
|
session_key: str = ""
|
|
username: str = ""
|
|
enabled: bool = False
|
|
|
|
|
|
class LastFmConnectionSettingsResponse(AppStruct):
|
|
api_key: str = ""
|
|
shared_secret: str = ""
|
|
session_key: str = ""
|
|
username: str = ""
|
|
enabled: bool = False
|
|
|
|
@classmethod
|
|
def from_settings(cls, settings: LastFmConnectionSettings) -> "LastFmConnectionSettingsResponse":
|
|
return cls(
|
|
api_key=settings.api_key,
|
|
shared_secret=_mask_secret(settings.shared_secret),
|
|
session_key=_mask_secret(settings.session_key),
|
|
username=settings.username,
|
|
enabled=settings.enabled,
|
|
)
|
|
|
|
|
|
class LastFmVerifyResponse(AppStruct):
|
|
valid: bool
|
|
message: str
|
|
|
|
|
|
class LastFmAuthTokenResponse(AppStruct):
|
|
token: str
|
|
auth_url: str
|
|
|
|
|
|
class LastFmAuthSessionRequest(AppStruct):
|
|
token: str
|
|
|
|
|
|
class LastFmAuthSessionResponse(AppStruct):
|
|
success: bool
|
|
message: 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):
|
|
primary_types: list[str] = msgspec.field(default_factory=lambda: ["album", "ep", "single"])
|
|
secondary_types: list[str] = msgspec.field(default_factory=lambda: ["studio"])
|
|
release_statuses: list[str] = msgspec.field(default_factory=lambda: ["official"])
|
|
download_options: DownloadOptions = msgspec.field(default_factory=DownloadOptions)
|
|
|
|
|
|
class LidarrConnectionSettings(AppStruct):
|
|
lidarr_url: str = "http://lidarr:8686"
|
|
lidarr_api_key: str = ""
|
|
quality_profile_id: int = 1
|
|
metadata_profile_id: int = 1
|
|
root_folder_path: str = "/music"
|
|
|
|
def __post_init__(self) -> None:
|
|
self.lidarr_url = self.lidarr_url.rstrip("/")
|
|
if self.quality_profile_id < 1:
|
|
raise msgspec.ValidationError("quality_profile_id must be >= 1")
|
|
if self.metadata_profile_id < 1:
|
|
raise msgspec.ValidationError("metadata_profile_id must be >= 1")
|
|
|
|
|
|
class JellyfinConnectionSettings(AppStruct):
|
|
jellyfin_url: str = "http://jellyfin:8096"
|
|
api_key: str = ""
|
|
user_id: str = ""
|
|
enabled: bool = False
|
|
|
|
def __post_init__(self) -> None:
|
|
self.jellyfin_url = self.jellyfin_url.rstrip("/")
|
|
|
|
|
|
NAVIDROME_PASSWORD_MASK = "********"
|
|
PLEX_TOKEN_MASK = "plex****"
|
|
|
|
|
|
class NavidromeConnectionSettings(AppStruct):
|
|
navidrome_url: str = ""
|
|
username: str = ""
|
|
password: str = ""
|
|
enabled: bool = False
|
|
|
|
def __post_init__(self) -> None:
|
|
self.navidrome_url = self.navidrome_url.rstrip("/") if self.navidrome_url else ""
|
|
|
|
|
|
class PlexConnectionSettings(AppStruct):
|
|
plex_url: str = ""
|
|
plex_token: str = ""
|
|
enabled: bool = False
|
|
music_library_ids: list[str] = []
|
|
scrobble_to_plex: bool = True
|
|
|
|
def __post_init__(self) -> None:
|
|
self.plex_url = self.plex_url.rstrip("/") if self.plex_url else ""
|
|
|
|
|
|
class PlexVerifyResponse(AppStruct):
|
|
valid: bool
|
|
message: str
|
|
libraries: list[PlexLibrarySectionInfo] = []
|
|
|
|
|
|
class PlexOAuthPinResponse(AppStruct):
|
|
pin_id: int
|
|
pin_code: str
|
|
auth_url: str
|
|
|
|
|
|
class PlexOAuthPollResponse(AppStruct):
|
|
completed: bool
|
|
auth_token: str = ""
|
|
|
|
|
|
class JellyfinUserInfo(AppStruct):
|
|
id: str
|
|
name: str
|
|
|
|
|
|
class JellyfinVerifyResponse(AppStruct):
|
|
success: bool
|
|
message: str
|
|
users: list[JellyfinUserInfo] = []
|
|
|
|
|
|
class ListenBrainzConnectionSettings(AppStruct):
|
|
username: str = ""
|
|
user_token: str = ""
|
|
enabled: bool = False
|
|
|
|
|
|
class YouTubeConnectionSettings(AppStruct):
|
|
api_key: str = ""
|
|
enabled: bool = False
|
|
api_enabled: bool = False
|
|
daily_quota_limit: int = 80
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.daily_quota_limit < 1 or self.daily_quota_limit > 10000:
|
|
raise msgspec.ValidationError("daily_quota_limit must be between 1 and 10000")
|
|
|
|
def has_valid_api_key(self) -> bool:
|
|
return bool(self.api_key and self.api_key.strip())
|
|
|
|
|
|
class HomeSettings(AppStruct):
|
|
cache_ttl_trending: int = 3600
|
|
cache_ttl_personal: int = 300
|
|
show_whats_hot: bool = True
|
|
show_globally_trending: bool = True
|
|
# Defaults False because Plex /status/sessions returns ALL active audio
|
|
# streams across the whole server with no library-section filter — on a
|
|
# shared instance that means anyone hitting the UI sees what every other
|
|
# household member is listening to. Local MusicSeerr playback (the user's
|
|
# own tab) is unaffected; this only gates the server-derived feed used
|
|
# by HomeSectionNowPlaying, SidebarVisualiser, and the /library/* pages.
|
|
show_now_playing: bool = False
|
|
|
|
|
|
class LocalFilesConnectionSettings(AppStruct):
|
|
enabled: bool = False
|
|
music_path: str = "/music"
|
|
# Lidarr'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):
|
|
success: bool
|
|
message: str
|
|
track_count: int = 0
|
|
|
|
|
|
class LidarrSettings(AppStruct):
|
|
sync_frequency: Literal["manual", "5min", "10min", "30min", "1hr", "6hr", "12hr", "24hr", "3d", "7d"] = "24hr"
|
|
last_sync: int | None = None
|
|
last_sync_success: bool = True
|
|
|
|
|
|
class LidarrProfileSummary(AppStruct):
|
|
id: int
|
|
name: str
|
|
|
|
|
|
class LidarrRootFolderSummary(AppStruct):
|
|
id: str
|
|
path: str
|
|
|
|
|
|
class LidarrVerifyResponse(AppStruct):
|
|
success: bool
|
|
message: str
|
|
quality_profiles: list[LidarrProfileSummary] = []
|
|
metadata_profiles: list[LidarrProfileSummary] = []
|
|
root_folders: list[LidarrRootFolderSummary] = []
|
|
|
|
|
|
class LidarrMetadataProfileSummary(AppStruct):
|
|
id: int
|
|
name: str
|
|
|
|
|
|
class ScrobbleSettings(AppStruct):
|
|
scrobble_to_lastfm: bool = False
|
|
scrobble_to_listenbrainz: bool = False
|
|
|
|
|
|
class PrimaryMusicSourceSettings(AppStruct):
|
|
source: Literal["listenbrainz", "lastfm"] = "listenbrainz"
|
|
|
|
|
|
_OFFICIAL_MB_RATE_LIMIT = 1.0
|
|
_OFFICIAL_MB_CONCURRENT_SEARCHES = 6
|
|
|
|
|
|
def is_official_musicbrainz(url: str) -> bool:
|
|
"""Check if the URL points to the official MusicBrainz API."""
|
|
from urllib.parse import urlparse
|
|
try:
|
|
parsed = urlparse(url.strip().rstrip("/"))
|
|
hostname = (parsed.hostname or "").lower()
|
|
return hostname in ("musicbrainz.org", "www.musicbrainz.org")
|
|
except (ValueError, AttributeError):
|
|
return False
|
|
|
|
|
|
class MusicBrainzConnectionSettings(AppStruct):
|
|
api_url: str = "https://musicbrainz.org/ws/2"
|
|
rate_limit: float = 1.0
|
|
concurrent_searches: int = 6
|
|
|
|
def __post_init__(self) -> None:
|
|
self.api_url = self.api_url.strip()
|
|
if not self.api_url or not self.api_url.startswith(("http://", "https://")):
|
|
self.api_url = "https://musicbrainz.org/ws/2"
|
|
self.api_url = self.api_url.rstrip("/")
|
|
if is_official_musicbrainz(self.api_url):
|
|
self.rate_limit = min(self.rate_limit, _OFFICIAL_MB_RATE_LIMIT)
|
|
self.concurrent_searches = min(self.concurrent_searches, _OFFICIAL_MB_CONCURRENT_SEARCHES)
|
|
if self.rate_limit < 0.1 or self.rate_limit > 50.0:
|
|
raise msgspec.ValidationError("rate_limit must be between 0.1 and 50.0")
|
|
if self.concurrent_searches < 1 or self.concurrent_searches > 30:
|
|
raise msgspec.ValidationError("concurrent_searches must be between 1 and 30")
|
|
|
|
|
|
class LidarrMetadataProfilePreferences(AppStruct):
|
|
profile_id: int
|
|
profile_name: str
|
|
primary_types: list[str] = []
|
|
secondary_types: list[str] = []
|
|
release_statuses: list[str] = []
|