Files
g33kphr33k 77810ff0c6 Stats stops slamming the door on users, and albums can wear their year
Stats: standard users had a tab whose only content was "Failed to load
statistics". /api/stats now scopes them to their own jobs and searches,
admins still see every account, peons still get nothing. Reset, mismatches
and the shared library size stay admin-only; a `scope` field says which.

Year on album folders (#101): album_folder_year, off by default, names new
folders "Album (2004)" like Lidarr. Album downloads and auto-routed singles
reuse an existing folder with or without a year. Tags take the album name
from .albuminfo, not the folder, which also stops a reused Lidarr folder's
"(2004)" leaking into the ALBUM tag, including via "Add to album...".

Built with a human and a grumpy-AI combo, the latter now fluent in Lidarr.
2026-10-08 10:45:12 +01:00

504 lines
19 KiB
Python

"""
MusicGrabber - Pydantic Request/Response Models
"""
import re
from datetime import date as _date
from typing import Optional
from pydantic import AliasChoices, BaseModel, Field, field_validator
from constants import DEFAULT_CONVERT_AUDIO, MAX_SEARCH_QUERY_LENGTH
_UUID_RE = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE)
def _convert_audio_field(default=DEFAULT_CONVERT_AUDIO, *, prefix=""):
"""Canonical conversion flag with the pre-v4 name as an input alias.
`prefix` covers the settings-level spelling (`default_convert_audio`), which
was renamed at the same time and for the same reason.
"""
return Field(
default=default,
validation_alias=AliasChoices(f"{prefix}convert_audio", f"{prefix}convert_to_flac"),
)
def _validate_mbid(v: str | None) -> str | None:
if v and not _UUID_RE.match(v):
raise ValueError("Invalid MBID format (expected UUID)")
return v
def _validate_subdir(v: str | None) -> str | None:
"""Keep a custom destination relative, and pointing inwards.
"Subdir" is doing a lot of quiet work in that field name: an absolute path or
a couple of ".." hops walks straight out of the music library and into the
rest of the container. Refuse both here so the user is told immediately,
rather than discovering it when a download lands somewhere surprising.
settings._join_within_music_dir() checks containment again at write time,
which is what catches values stored before this validator existed.
"""
if v is None:
return v
cleaned = v.strip()
if not cleaned:
# Empty stays empty rather than becoming None: callers treat None as
# "field not supplied", so folding the two together would quietly remove
# the user's ability to clear the setting again.
return cleaned
# Windows-style separators get normalised first, otherwise "..\\.." sails past.
parts = cleaned.replace("\\", "/").split("/")
if cleaned.startswith(("/", "~")):
raise ValueError("Folder must be relative to your music directory, not an absolute path")
if ".." in parts:
raise ValueError("Folder cannot contain '..'")
return cleaned
def _validate_from_date(v: str | None) -> str | None:
"""A from_date is compared against MusicBrainz release dates as a plain string,
so anything that is not a real ISO date sorts somewhere daft and quietly filters
out either everything or nothing. Better to refuse it at the door."""
if v is None:
return v
try:
_date.fromisoformat(v)
except ValueError:
raise ValueError("Invalid from_date (expected YYYY-MM-DD)")
return v
class SearchRequest(BaseModel):
query: str = Field(..., min_length=1, max_length=MAX_SEARCH_QUERY_LENGTH)
limit: int = 15
source: str = "all" # Registry source ID, or "all"
class AlbumTrackSelection(BaseModel):
artist: str = Field(min_length=1)
album_title: str = Field(min_length=1)
release_mbid: str
_validate_release_mbid = field_validator("release_mbid")(_validate_mbid)
track_index: int = Field(ge=0)
album_dir: Optional[str] = None
track_title: Optional[str] = None
disc_number: Optional[int] = Field(default=None, ge=1)
track_number: Optional[int] = Field(default=None, ge=1)
class AlbumTrackRepairRequest(AlbumTrackSelection):
trash_existing: bool = False
class DownloadRequest(BaseModel):
video_id: str
title: str
artist: Optional[str] = None
search_token: Optional[str] = None
# Duration advertised by the exact search result the user clicked. This is
# independent of canonical MusicBrainz duration, so intentional live/remix
# choices remain valid while CDN samples can be recognised after download.
selected_duration_secs: Optional[float] = Field(
default=None, gt=0, le=86400, allow_inf_nan=False,
)
download_type: str = "single" # "single" or "playlist"
convert_audio: bool = _convert_audio_field()
# Source routing
source: str = "youtube" # Registry source ID
source_url: Optional[str] = None # Full URL for non-YouTube sources (e.g. SoundCloud/zvu4no)
# Soulseek-specific fields
slskd_username: Optional[str] = None
slskd_filename: Optional[str] = None
slskd_size: Optional[int] = None
# Playlist routing - optional, defaults to Singles
playlist_name: Optional[str] = None # Name of target playlist (M3U stem)
use_playlists_dir: bool = False # Route into Playlists dir instead of Singles
# Album routing (search results -> selected album track)
album_release_mbid: Optional[str] = None
_validate_album_release_mbid = field_validator("album_release_mbid")(_validate_mbid)
album_artist: Optional[str] = None
album_name: Optional[str] = None
album_track_title: Optional[str] = None
album_track_number: Optional[int] = None
album_track_total: Optional[int] = None
# An explicit album rescue selection; metadata and destination are resolved
# again by the server rather than trusting a candidate's album tags.
album_repair: Optional[AlbumTrackSelection] = None
class PlaylistFetchRequest(BaseModel):
url: str # Spotify, YouTube, Apple Music, Amazon Music, SoundCloud, etc. playlist URL
class LongformDetectRequest(BaseModel):
url: str # A single YouTube video URL, checked for chapters/cue-sheet-comment split candidacy
class LongformSegmentInput(BaseModel):
start_seconds: float
end_seconds: float
title: str
artist: Optional[str] = None
class LongformSplitRequest(BaseModel):
url: str
video_title: str
album_artist: str
segments: list[LongformSegmentInput]
class AsyncBulkImportRequest(BaseModel):
songs: str # Multi-line text with "Artist - Song" format
create_playlist: bool = False
playlist_name: Optional[str] = None
playlist_source_url: Optional[str] = None
convert_audio: bool = _convert_audio_field()
use_playlists_dir: bool = False # Save files to Playlists folder instead of Singles
preferred_sources: Optional[str] = None # Comma-separated source IDs or "all"
priority_source: Optional[str] = None # Tie-breaker within the same automatic quality tier
original_youtube_video_ids: Optional[list[Optional[str]]] = None
class WatchedPlaylistRequest(BaseModel):
url: str # Spotify, YouTube, Apple Music, Amazon Music, SoundCloud, etc. playlist URL
refresh_interval_hours: float = 24
convert_audio: bool = _convert_audio_field()
make_m3u: bool = False
use_playlists_dir: bool = False # Save files to Playlists folder instead of Singles
sync_mode: str = "append" # "append" = grow forever; "mirror" = track upstream removals in M3U
preferred_sources: str = "all" # Comma-separated source IDs or "all"
priority_source: Optional[str] = None # Tie-breaker within the same automatic quality tier
custom_subdir: Optional[str] = None # Override destination folder (relative to music_dir)
redownload_missing_files: Optional[bool] = None # None inherits the global setting
_validate_custom_subdir = field_validator("custom_subdir")(_validate_subdir)
class WatchedPlaylistUpdate(BaseModel):
refresh_interval_hours: Optional[float] = None
enabled: Optional[bool] = None
convert_audio: Optional[bool] = _convert_audio_field(None)
make_m3u: Optional[bool] = None
use_playlists_dir: Optional[bool] = None
sync_mode: Optional[str] = None # "append" or "mirror"
preferred_sources: Optional[str] = None # Comma-separated source IDs or "all"
priority_source: Optional[str] = None # Same-tier tie-breaker; empty string clears it
custom_subdir: Optional[str] = None # Override destination folder (relative to music_dir)
redownload_missing_files: Optional[bool] = None # Explicit null restores global inheritance
_validate_custom_subdir = field_validator("custom_subdir")(_validate_subdir)
class SettingsUpdate(BaseModel):
"""Settings that can be updated via the UI"""
# General
music_dir: Optional[str] = None
enable_musicbrainz: Optional[bool] = None
enable_deezer_metadata: Optional[bool] = None
enable_lyrics: Optional[bool] = None
update_check_enabled: Optional[bool] = None
default_convert_audio: Optional[bool] = _convert_audio_field(None, prefix="default_")
audio_format: Optional[str] = None # "flac", "alac", "opus", or "mp3"
mp3_bitrate: Optional[str] = None # "v0", "v2", or "320k"/"256k"/"192k"/"128k"
opus_bitrate: Optional[str] = None # "320k"/"256k"/"192k"/"128k"/"96k"
alac_bitrate: Optional[str] = None # "lossless" (true ALAC) or AAC kbps "320k"/"256k"/"192k"/"128k"
convert_lossless_only: Optional[bool] = None
min_audio_bitrate: Optional[int] = None
normalise_lossy_audio: Optional[bool] = None
enable_replaygain: Optional[bool] = None
replaygain_replace_existing: Optional[bool] = None
auto_import_dir: Optional[str] = None
reject_live_versions: Optional[bool] = None
reject_version_keywords: Optional[str] = None
playlist_comment_tagging: Optional[bool] = None
redownload_missing_files: Optional[bool] = None
enable_track_upgrades: Optional[bool] = None
upgrade_scan_interval_hours: Optional[int] = None
singles_subdir: Optional[str] = None
playlists_subdir: Optional[str] = None
albums_subdir: Optional[str] = None
# These three are user-scoped, so they are every bit as user-supplied as a
# watched playlist's custom folder, and get the same treatment.
_validate_library_subdirs = field_validator(
"singles_subdir", "playlists_subdir", "albums_subdir"
)(_validate_subdir)
organise_by_artist: Optional[bool] = None
include_track_number_in_filename: Optional[bool] = None
auto_album_singles: Optional[bool] = None
album_folder_year: Optional[bool] = None
playlist_album_as_name: Optional[bool] = None
singles_only_mode: Optional[bool] = None
file_permissions: Optional[str] = None
@field_validator("file_permissions")
@classmethod
def validate_file_permissions(cls, v):
if v is not None and v not in ("666", "777"):
raise ValueError("file_permissions must be 666 or 777")
return v
# Search sources
source_youtube_enabled: Optional[bool] = None
source_soundcloud_enabled: Optional[bool] = None
source_zvu4no_enabled: Optional[bool] = None
source_freemp3cloud_enabled: Optional[bool] = None
source_jiosaavn_enabled: Optional[bool] = None
source_soulseek_enabled: Optional[bool] = None
source_monochrome_enabled: Optional[bool] = None
source_offline_fallback: Optional[bool] = None
youtube_requested_video_fallback: Optional[bool] = None
source_health_checks_enabled: Optional[bool] = None
source_health_check_interval_minutes: Optional[int] = None
source_health_cooldown_minutes: Optional[int] = None
search_concurrency: Optional[int] = Field(default=None, ge=1, le=5)
# Soulseek/slskd
slskd_url: Optional[str] = None
slskd_user: Optional[str] = None
slskd_pass: Optional[str] = None
slskd_downloads_path: Optional[str] = None
slskd_move_completed: Optional[bool] = None
# Duplicate checking
skip_dupes: Optional[bool] = None
navidrome_dupe_check: Optional[bool] = None
# Navidrome
navidrome_url: Optional[str] = None
navidrome_user: Optional[str] = None
navidrome_pass: Optional[str] = None
# Jellyfin
jellyfin_url: Optional[str] = None
jellyfin_api_key: Optional[str] = None
# Plex
plex_url: Optional[str] = None
plex_token: Optional[str] = None
# Lidarr
lidarr_url: Optional[str] = None
lidarr_api_key: Optional[str] = None
# Notifications
notify_on: Optional[str] = None
telegram_webhook_url: Optional[str] = None
smtp_host: Optional[str] = None
smtp_port: Optional[int] = None
smtp_user: Optional[str] = None
smtp_pass: Optional[str] = None
smtp_from: Optional[str] = None
smtp_to: Optional[str] = None
smtp_tls: Optional[bool] = None
# Apprise notifications
apprise_url: Optional[str] = None
# YouTube
youtube_cookies: Optional[str] = None
spotify_browser_timeout_seconds: Optional[int] = None
spotify_browser_stall_seconds: Optional[int] = None
# Spotify
spotify_cookies: Optional[str] = None
# Apple Music
apple_music_user_token: Optional[str] = None
# Security
api_key: Optional[str] = None
class SearchResult(BaseModel):
video_id: str
title: str
artist: Optional[str] = None
channel: str
duration: str
thumbnail: str
is_playlist: bool = False
video_count: Optional[int] = None
# Multi-source support
source: str = "youtube" # Registry source ID
source_url: Optional[str] = None # Full URL for non-YouTube sources
quality: Optional[str] = None # e.g., None for YouTube, format string for others
relevance_score: int = 40 # Artist/title/duration/source rank; higher is better
# 0 = source declined to say (YouTube/SoundCloud), 1-5 = the shared tier
# scale in constants.py. Drives the UI's minimum-quality filter.
quality_tier: int = 0
slskd_username: Optional[str] = None
slskd_filename: Optional[str] = None
slskd_size: Optional[int] = None
album: Optional[str] = None
year: Optional[int] = None
size_bytes: Optional[int] = None # File size where the source declares one
bitrate: Optional[int] = None # kbps, declared or worked out from size/duration
match_confidence: Optional[float] = None # 0.0-1.0, Soulseek path scoring only
queue_length: Optional[int] = None # Soulseek peer queue depth at search time
class BlacklistRequest(BaseModel):
"""Report a bad track / block an uploader."""
job_id: Optional[str] = None
video_id: Optional[str] = None
uploader: Optional[str] = None
source: str = "youtube" # Registry source ID
reason: str = "other" # wrong_track, poor_quality, slowed_pitched, contentid, other
note: Optional[str] = None # Optional free-text detail
block_uploader: bool = False # Also blacklist the uploader
class TestSlskdRequest(BaseModel):
url: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
downloads_path: Optional[str] = None
class TestNavidromeRequest(BaseModel):
url: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
class TestJellyfinRequest(BaseModel):
url: Optional[str] = None
api_key: Optional[str] = None
class TestPlexRequest(BaseModel):
url: Optional[str] = None
token: Optional[str] = None
class TestLidarrRequest(BaseModel):
url: Optional[str] = None
api_key: Optional[str] = None
class TestAppriseRequest(BaseModel):
url: Optional[str] = None
class TestEmailRequest(BaseModel):
smtp_host: Optional[str] = None
smtp_port: Optional[int] = None
smtp_user: Optional[str] = None
smtp_pass: Optional[str] = None
smtp_from: Optional[str] = None
smtp_to: Optional[str] = None
smtp_tls: Optional[bool] = None
class TestYouTubeCookiesRequest(BaseModel):
cookies: Optional[str] = None
class TestSpotifyCookiesRequest(BaseModel):
cookies: Optional[str] = None
class WatchedArtistRequest(BaseModel):
mbid: str
name: str
from_date: str # YYYY-MM-DD
refresh_interval_hours: float = 24
convert_audio: bool = _convert_audio_field()
auto_add_albums: bool = False # Seeds current albums as already-seen; only later arrivals get queued
watch_singles: bool = True # Off for an albums-only follow, so no singles get hoovered up
_validate_mbid = field_validator("mbid")(_validate_mbid)
class WatchedArtistUpdate(BaseModel):
enabled: Optional[bool] = None
refresh_interval_hours: Optional[float] = None
convert_audio: Optional[bool] = _convert_audio_field(None)
from_date: Optional[str] = None
auto_add_albums: Optional[bool] = None
watch_singles: Optional[bool] = None
_validate_from_date = field_validator("from_date")(_validate_from_date)
class AlbumDownloadRequest(BaseModel):
artist: str
album_title: str
release_mbid: str
make_m3u: bool = False
m3u_name: Optional[str] = None
convert_audio: bool = _convert_audio_field()
# Set when the grab came from a followed artist's album list, so the album is
# recorded as already dealt with and auto-add doesn't cheerfully queue it again.
watch_artist_id: Optional[str] = None
release_group_mbid: Optional[str] = None
_validate_release_mbid = field_validator("release_mbid")(_validate_mbid)
class ReleaseGroupResolveRequest(BaseModel):
"""Turn a chosen release-group into a concrete release, ready to download."""
release_group_mbid: str
_validate_release_group_mbid = field_validator("release_group_mbid")(_validate_mbid)
class RetryMissingTrackRequest(BaseModel):
artist: str
title: str
class OrphanMoveRequest(BaseModel):
"""Absolute paths of orphaned playlist files to move into Singles"""
files: list[str]
class MaintenanceRequest(BaseModel):
action: str
statuses: list[str] = Field(default_factory=list)
confirm: bool = False
class QueueMissingTrackCandidateRequest(BaseModel):
artist: str
title: str
video_id: str
source: str = "youtube"
source_url: Optional[str] = None
slskd_username: Optional[str] = None
slskd_filename: Optional[str] = None
slskd_size: Optional[int] = None
class QueueRescueCandidateRequest(BaseModel):
candidate_key: str = Field(..., min_length=8, max_length=64)
class PatchTagsRequest(BaseModel):
artist: str = Field(..., min_length=1, max_length=200)
title: str = Field(..., min_length=1, max_length=200)
album: str = Field("", max_length=200)
album_artist: str = Field("", max_length=200)
year: str = Field("", max_length=4)
track_number: Optional[int] = Field(None, ge=1, le=999)
track_total: Optional[int] = Field(None, ge=1, le=999)
class ExploreRequest(BaseModel):
artist: str
mode: str = "easy" # easy / medium / hard
limit: int = 25
# Auth and user management models
class LoginRequest(BaseModel):
username: str
password: str
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
class CreateUserRequest(BaseModel):
username: str
password: str
role: str = "user"
class SetUserPasswordRequest(BaseModel):
new_password: str
class SetUserRoleRequest(BaseModel):
role: str
class DownloadTokenRequest(BaseModel):
job_id: str
class M3uEntryPick(BaseModel):
"""One row of an edited playlist: an existing entry by position, or a library file."""
index: Optional[int] = None
file: Optional[str] = None
class M3uSaveRequest(BaseModel):
path: str
mtime_ns: str
entries: list[M3uEntryPick]
class M3uCreateRequest(BaseModel):
name: str
copy_from: Optional[str] = None
mtime_ns: Optional[str] = None
entries: Optional[list[M3uEntryPick]] = None
class LibraryMatchRequest(BaseModel):
"""Absolute path of a library file chosen to stand in for a missing watched track."""
path: str