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

6987 lines
299 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Music Grabber - A self-hosted music acquisition service
Searches music sources, downloads best quality audio with optional conversion to FLAC/Opus/MP3, drops into Navidrome/Jellyfin library
"""
import csv
import io
import json
import os
import re
import sqlite3
import subprocess
import tempfile
import uuid
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
import httpx
from constants import (
VERSION, MUSIC_DIR, DB_PATH, COOKIES_FILE, ROOT_PATH,
TIMEOUT_YTDLP_INFO,
TIMEOUT_YTDLP_PREVIEW,
TIMEOUT_SLSKD_SEARCH,
WATCHED_PLAYLIST_CHECK_HOURS,
SEARCH_LOG_RETENTION_DAYS,
STALE_JOB_TIMEOUT,
DOWNLOAD_TOKEN_TTL_SECONDS,
STREAM_TOKEN_TTL_SECONDS,
AUDIO_EXTENSIONS,
SEARCH_SLOT_WAIT_INTERACTIVE,
SEARCH_SLOT_WAIT_AUTOMATED,
)
from db import (
db_conn, init_db, start_stale_job_monitor, cleanup_stale_jobs,
cleanup_old_search_logs, upsert_album_track_lock, save_search_decision,
reconcile_deleted_library_files,
)
from settings import (
get_setting, get_setting_bool, set_setting, set_user_setting, get_singles_dir, get_playlists_dir,
get_albums_dir, migrate_trash_to_per_user,
SETTINGS_SCHEMA, SENSITIVE_SETTINGS, USER_SETTINGS_KEYS, _get_typed_setting, _is_env_override,
)
from source_urls import is_valid_source_url, validate_source_url
from models import (
SearchRequest, DownloadRequest, PlaylistFetchRequest, AlbumTrackRepairRequest,
AsyncBulkImportRequest, WatchedPlaylistRequest, WatchedPlaylistUpdate,
WatchedArtistRequest, WatchedArtistUpdate,
SettingsUpdate, SearchResult, BlacklistRequest,
TestSlskdRequest, TestNavidromeRequest, TestJellyfinRequest, TestPlexRequest, TestLidarrRequest, TestYouTubeCookiesRequest,
TestAppriseRequest, TestEmailRequest, TestSpotifyCookiesRequest, RetryMissingTrackRequest, QueueMissingTrackCandidateRequest,
QueueRescueCandidateRequest,
OrphanMoveRequest, MaintenanceRequest,
AlbumDownloadRequest, ReleaseGroupResolveRequest, ExploreRequest, PatchTagsRequest,
LoginRequest, ChangePasswordRequest, CreateUserRequest,
SetUserPasswordRequest, SetUserRoleRequest,
DownloadTokenRequest,
LongformDetectRequest, LongformSplitRequest,
M3uSaveRequest, M3uCreateRequest, LibraryMatchRequest,
)
from middleware import AuthMiddleware, invalidate_users_cache
from auth import (
verify_password, create_session, delete_session, get_user_by_username,
get_user_by_id, list_users, create_user, update_password, delete_user,
clear_failed_login, create_download_token, create_stream_token, is_login_allowed,
password_hash_for_timing, record_failed_login,
)
from youtube import (
_has_valid_cookie_entries, _cookie_lines_for_domain_check, _sync_cookies_file,
_ytdlp_base_args, _is_ytdlp_403, parse_duration,
get_cookies_expiry, _parse_query_artist_title,
)
from search import (
search_source, search_all, _search_all_events, get_available_sources,
clear_automated_search_cache, SOURCE_REGISTRY, project_search_result,
SourceSearchBusy,
)
from library_scans import snapshots, scan_playlists, scan_storage
import servicecheck
from slskd import slskd_enabled, search_slskd
from downloads import (
process_acquisition_cycle, process_download, process_playlist_download,
process_slskd_download,
rebuild_watched_playlist_m3u, _refresh_album_m3u_if_present,
trigger_navidrome_scan, trigger_jellyfin_scan, trigger_plex_scan,
jellyfin_auth_headers,
check_navidrome_duplicate, check_lidarr_duplicate,
get_job_source_allowlist,
)
from acquisition import (
acquisition_candidate_summary,
attach_job_to_acquisition,
begin_acquisition_cycle,
ensure_acquisition_target,
finish_acquisition_cycle,
parse_allowed_sources,
rank_automatic_candidates,
stored_rescue_candidates,
)
from bulk_import import (
clean_bulk_import_line,
start_bulk_import_for_tracks,
process_bulk_import_worker,
request_bulk_import_cancellation,
)
from watched_playlists import (
detect_playlist_platform, fetch_playlist_tracks, refresh_watched_playlist,
fetch_listenbrainz_createdfor, start_scheduler, wake_scheduler,
)
from watched_artists import refresh_watched_artist, start_artist_scheduler, wake_artist_scheduler, seed_artist_albums
import discovery
import library_index
import playlist_files
from library_index import start_library_index_scheduler
from upgrades import (
start_upgrade_scheduler, run_scan_all,
get_candidates_page, search_candidate, dismiss_candidate,
perform_upgrade, perform_upgrade_all, get_upgrade_all_progress,
mark_upgrade_all_starting,
list_quarantine, restore_from_quarantine, delete_quarantine_entry,
delete_orphaned_quarantine_file,
)
from audio_provenance import (
get_audit_export,
get_audit_files,
get_audit_status,
prioritise_over_audio_audit,
start_audit_scan,
)
from metadata import (
search_artist_mbid, fetch_artist_albums, fetch_album_tracks, resolve_album_release,
apply_metadata_to_file, guess_musicbrainz_tags, MusicBrainzUnavailable,
parse_musicbrainz_release_url, fetch_release_summary, search_release_groups,
)
from album_urls import resolve_album_url
from albums import (
album_audio_file_count,
album_track_status,
resolve_album_track_repair, album_repair_files,
queue_album_download,
album_on_disk,
watched_album_display_state,
AlbumTracklistUnavailable,
_normalise_album_match_text,
)
from utils import authoritative_audio_file, hash_track, is_valid_youtube_id, iter_library_audio_files, sanitize_filename, sanitize_playlist_name, set_file_permissions, spawn_daemon_thread, subsonic_auth_params
from coverart import fetch_cover_art_url
from notifications import send_test_email
from updates import get_update_status
from longform_split import (
detect_longform_candidate, validate_segments, create_longform_split, cancel_longform_split,
)
URL_BASED_SOURCES = {"soundcloud", "zvu4no", "freemp3cloud", "jiosaavn", "monochrome"}
DIRECT_PREVIEW_SOURCES = {"zvu4no", "freemp3cloud", "jiosaavn"}
MONOCHROME_PREVIEW_SOURCES = {"monochrome"}
def _request_root_path(request: Request | None = None) -> str:
"""Return the externally-visible app prefix, normalised like '/musicgrabber'."""
raw = ""
if request is not None:
raw = (request.scope.get("root_path") or "").strip()
raw = raw or ROOT_PATH
if not raw or raw == "/":
return ""
return "/" + raw.strip("/")
def _app_path(path: str, request: Request | None = None) -> str:
"""Prefix an app-local path with the configured root path."""
path = path if path.startswith("/") else f"/{path}"
root_path = _request_root_path(request)
return f"{root_path}{path}" if root_path else path
def _user_scope(user_id: str | None, is_admin: bool) -> tuple[str, tuple]:
"""Return a SQL WHERE fragment and params for scoping rows to the current user.
Admins see their own rows plus legacy rows (user_id IS NULL) left over from
single-user mode. Regular users see only their own rows; legacy rows are
the admin's history, not theirs.
Usage:
frag, params = _user_scope(user_id, is_admin)
conn.execute(f"SELECT ... FROM jobs WHERE {frag}", params)
"""
if is_admin:
return "(user_id = ? OR user_id IS NULL)", (user_id,)
return "user_id = ?", (user_id,)
def _is_peon(request: Request) -> bool:
user = getattr(request.state, "user", None) or {}
return user.get("role") == "peon"
def _enforce_peon_format(request: Request, body) -> None:
"""Peons cannot toggle conversion on or off. Force their requests onto whatever
the admin has set globally for the default conversion mode. Belt to the UI's braces:
the toggle is hidden, but a peon with the dev tools open could still try to send
their own value, so we overwrite it server-side."""
if not _is_peon(request):
return
if hasattr(body, "convert_audio"):
body.convert_audio = get_setting_bool("default_convert_audio", False)
# =============================================================================
# Application Setup
# =============================================================================
@asynccontextmanager
async def _lifespan(_app: FastAPI):
"""Boot work runs when uvicorn starts the app, not when something imports it.
Unit tests import this module for its route functions, and used to get the
whole boot thrown in: init_db against whichever DB they had patched in,
every scheduler, and a health sweep probing each source over the real
network at whatever moment it got round to it."""
# Ensure directories exist
get_singles_dir().mkdir(parents=True, exist_ok=True)
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
# Initialise database and start background monitors
init_db()
migrate_trash_to_per_user() # Reshape the bin if the account count has changed
cleanup_old_search_logs(SEARCH_LOG_RETENTION_DAYS)
start_stale_job_monitor()
start_scheduler()
start_artist_scheduler()
start_upgrade_scheduler()
# The library index reconciles on its own, well after the schedulers above have
# settled, and nothing reads it yet except the storage tile. If it cannot open
# its database it says so once and everything carries on walking the disk.
start_library_index_scheduler()
servicecheck.start_health_checks() # initial background health sweep at boot
# Sync cookies file from settings at startup
_sync_cookies_file()
yield
app = FastAPI(title="Music Grabber", version=VERSION, root_path=ROOT_PATH, lifespan=_lifespan)
app.mount("/static", StaticFiles(directory="static"), name="static")
# Register middleware
app.add_middleware(AuthMiddleware)
# =============================================================================
# Basic Routes
# =============================================================================
def _static_cache_bust() -> str:
"""Cache-bust token for static assets.
VERSION alone is not enough: within a (DEV) cycle the version stays put while
app.js / index.html change repeatedly, so the browser would happily serve a
stale cached app.js against the same ?v=. We fold in the newest static-file
mtime (set fresh by the Docker COPY on every build) so any change to the
served files busts the cache, version bump or not.
"""
try:
static_dir = Path("static")
names = ("app.js", "index.html", "release-notes.js", "style.css", "quips.js")
mtimes = [(static_dir / n).stat().st_mtime for n in names if (static_dir / n).exists()]
stamp = int(max(mtimes)) if mtimes else 0
return f"{VERSION}.{stamp}"
except Exception:
return VERSION
@app.get("/", response_class=HTMLResponse)
def root(request: Request):
"""Serve the main UI"""
html = Path("static/index.html").read_text(encoding="utf-8")
root_path = _request_root_path(request)
html = html.replace("__ROOT_PATH__", root_path)
# Cache-bust static assets so browser fetches fresh files after an update
html = html.replace("__CACHE_BUST__", _static_cache_bust())
return HTMLResponse(content=html)
@app.get("/favicon.ico", include_in_schema=False)
def favicon():
return FileResponse("static/favicon.png", media_type="image/png")
def _is_volume_mounted() -> bool:
"""Check if the configured music directory appears to be a mounted volume.
Compares device IDs: if the music dir is on a different device than /,
it's likely a mounted volume. This helps detect misconfigured setups
where users forgot to mount their music directory.
"""
try:
configured_music_dir = Path(get_setting("music_dir", str(MUSIC_DIR)))
root_stat = os.stat("/")
music_stat = os.stat(configured_music_dir)
# Different device ID means it's a mount point
return root_stat.st_dev != music_stat.st_dev
except OSError:
# Can't stat, assume it's fine
return True
@app.get("/api/config")
def get_config(request: Request):
"""Expose server configuration and version for the UI"""
api_key = get_setting("api_key", "")
organise_by_artist = get_setting_bool("organise_by_artist", True)
configured_music_dir = Path(get_setting("music_dir", str(MUSIC_DIR)))
singles_dir = get_singles_dir()
playlists_dir = get_playlists_dir()
# Build human-readable example paths relative to the music root so the
# frontend can show the user exactly where their files will land.
try:
singles_example = str(singles_dir.relative_to(configured_music_dir))
except ValueError:
singles_example = str(singles_dir)
if organise_by_artist:
singles_example += "/Artist Name"
singles_example += "/Track Title.flac"
playlists_example = None
if playlists_dir:
try:
pl_rel = str(playlists_dir.relative_to(configured_music_dir))
except ValueError:
pl_rel = str(playlists_dir)
playlists_example = f"{pl_rel}/Playlist Name/Artist - Title.flac"
with db_conn() as conn:
user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
# Multi-user mode kicks in only when there are 2+ accounts. A single-user
# install with one account (the owner) stays login-free, same as no-account mode.
users_exist = user_count >= 2
default_convert_audio = get_setting_bool("default_convert_audio", False)
return {
"version": VERSION,
"listen_addr": os.getenv("LISTEN_ADDR") or "0.0.0.0",
"listen_port": int(os.getenv("LISTEN_PORT") or "8080"),
"default_convert_audio": default_convert_audio,
# Compatibility for pre-v4 browsers and API clients. The stored setting and
# its column have both been renamed; this key is emitted purely so an older
# cached frontend or a third-party script does not lose its footing.
"default_convert_to_flac": default_convert_audio,
"audio_format": get_setting("audio_format", "opus"),
"playlists_subdir": get_setting("playlists_subdir", ""),
"organise_by_artist": organise_by_artist,
"singles_path_example": singles_example,
"playlists_path_example": playlists_example,
"music_dir": get_setting("music_dir", str(MUSIC_DIR)),
"root_path": _request_root_path(request),
"auth_required": bool(api_key),
"auth_mode": "session",
"users_exist": users_exist,
"volume_mounted": _is_volume_mounted(),
"singles_only_mode": get_setting_bool("singles_only_mode", False),
}
@app.get("/api/update-check")
def update_check():
"""Is there a newer release than the one running? Cached; see updates.py."""
return get_update_status()
# =============================================================================
# Music directory listing (for subfolder picker)
# =============================================================================
@app.get("/api/music-dirs")
@prioritise_over_audio_audit
def list_music_dirs(path: str = "", recursive: bool = False, max_depth: int | None = None):
"""List subdirectories of MUSIC_DIR (or a subpath) for the subfolder picker.
Returns directory names/paths only - no hidden/system dirs, sorted alphabetically.
Filters out dotfiles and @-prefixed system dirs (Synology, etc.).
The path parameter lets users browse deeper into the tree.
Set recursive=true to list descendants as full paths relative to MUSIC_DIR.
Optionally pass max_depth to cap recursive traversal depth.
"""
# Sanitise: normalise separators, strip edges, reject traversal segments
clean = path.strip().replace("\\", "/").strip("/")
segments = [segment for segment in clean.split("/") if segment and segment != "."]
if any(segment == ".." for segment in segments):
raise HTTPException(status_code=400, detail="Path traversal not allowed")
clean = "/".join(segments)
configured_music_dir = Path(get_setting("music_dir", str(MUSIC_DIR)))
target = configured_music_dir / clean if clean else configured_music_dir
target_resolved = target.resolve()
music_root = configured_music_dir.resolve()
# Make sure we haven't escaped MUSIC_DIR
try:
target.resolve().relative_to(music_root)
except ValueError:
raise HTTPException(status_code=400, detail="Path outside music directory")
if max_depth is not None and max_depth < 1:
raise HTTPException(status_code=400, detail="max_depth must be >= 1")
try:
if recursive:
dirs = []
if target.is_dir():
for dirpath, dirnames, _ in os.walk(target):
# Prune hidden/system directories at each level.
dirnames[:] = [
name for name in dirnames
if not name.startswith((".", "@"))
]
rel_from_target = Path(dirpath).resolve().relative_to(target_resolved)
depth = len(rel_from_target.parts)
if max_depth is not None and depth >= max_depth:
dirnames[:] = []
rel = Path(dirpath).resolve().relative_to(music_root).as_posix()
if rel in {".", clean}:
continue
if max_depth is not None and depth > max_depth:
continue
dirs.append(rel)
dirs.sort(key=str.casefold)
else:
dirs = sorted(
[
d.name for d in target.iterdir()
if d.is_dir() and not d.name.startswith((".", "@"))
],
key=str.casefold,
)
except FileNotFoundError:
dirs = []
return {
"path": clean,
"directories": dirs,
}
# =============================================================================
# Playlist listing (watched + physical .m3u files)
# =============================================================================
@app.get("/api/playlists")
@prioritise_over_audio_audit
def list_playlists(http_request: Request):
"""List available playlists for the playlist routing selector.
Returns watched playlists (from DB) and any physical .m3u files found in the
Playlists directory. Watched playlists take priority if names collide.
"""
user_id = getattr(http_request.state, "user_id", None)
is_admin = getattr(http_request.state, "is_admin", False)
results = {}
# Physical .m3u files from Playlists dir (lowest priority)
playlists_dir = get_playlists_dir(user_id=user_id)
physical, scan_state = snapshots.get(
("playlists", user_id, str(playlists_dir)),
lambda: scan_playlists(playlists_dir),
)
for name in physical or []:
results[name] = {
"name": name, "is_watched": False, "watched_id": None, "platform": None,
}
# Watched playlists from DB (higher priority - overwrite any same-named .m3u entry)
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
with db_conn() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
f"SELECT id, name, platform, sync_mode FROM watched_playlists WHERE {_scope_frag} ORDER BY name COLLATE NOCASE",
_scope_params
).fetchall()
for row in rows:
results[row["name"]] = {
"name": row["name"],
"is_watched": True,
"watched_id": row["id"],
"platform": row["platform"],
"sync_mode": row["sync_mode"] or "append",
}
return {"playlists": sorted(results.values(), key=lambda p: p["name"].casefold()),
"scan_state": scan_state, "scan_ready": physical is not None}
# =============================================================================
# M3U playlist files (the editor on the Watched tab's M3U Files pill)
# =============================================================================
def _m3u_call(fn, *args, **kwargs):
try:
return fn(*args, **kwargs)
except playlist_files.PlaylistFileError as e:
raise HTTPException(status_code=e.status, detail=e.detail)
def _no_peons(http_request: Request):
# The Playlists folder is often shared, and a peon is not trusted with
# anybody else's playlist, so the editor stays behind the same rope as Audit.
if _is_peon(http_request):
raise HTTPException(status_code=403, detail="Playlist editing is not available to this account")
@app.get("/api/m3u")
def list_m3u_files(http_request: Request):
"""Every M3U in the Playlists folder (or Singles root), with entry and missing counts."""
_no_peons(http_request)
user_id = http_request.state.user_id
return {
"playlists": _m3u_call(playlist_files.list_playlists, user_id),
"default_dir": str(playlist_files.default_dir(user_id)),
}
@app.get("/api/m3u/file")
def read_m3u_file(http_request: Request, path: str = ""):
_no_peons(http_request)
return _m3u_call(playlist_files.read_playlist, http_request.state.user_id, path)
@app.put("/api/m3u/file")
def save_m3u_file(body: M3uSaveRequest, http_request: Request):
_no_peons(http_request)
return _m3u_call(
playlist_files.save_playlist, http_request.state.user_id, body.path, body.mtime_ns,
[e.model_dump() for e in body.entries],
)
@app.post("/api/m3u")
def create_m3u_file(body: M3uCreateRequest, http_request: Request):
"""A new empty playlist, or Save as copy when copy_from is given."""
_no_peons(http_request)
return _m3u_call(
playlist_files.create_playlist, http_request.state.user_id, body.name,
copy_from=body.copy_from, mtime_ns=body.mtime_ns,
entries=[e.model_dump() for e in body.entries] if body.entries is not None else None,
)
@app.delete("/api/m3u/file")
def delete_m3u_file(http_request: Request, path: str = ""):
"""Bin the M3U. The tracks it lists stay exactly where they are."""
_no_peons(http_request)
return _m3u_call(playlist_files.delete_playlist, http_request.state.user_id, path)
@app.get("/api/library/search")
def search_library_files(http_request: Request, q: str = "", limit: int = 50):
"""The library-file picker: indexed files whose tags or path contain every word."""
return playlist_files.search_library(http_request.state.user_id, q, limit=max(1, min(limit, 100)))
# =============================================================================
# Auth & User Management Routes
# =============================================================================
@app.post("/api/auth/login")
def login(body: LoginRequest):
allowed, _retry_after = is_login_allowed(body.username)
if not allowed:
# Keep the same generic response to avoid leaking account state.
raise HTTPException(status_code=401, detail="Invalid username or password")
user = get_user_by_username(body.username)
password_ok = verify_password(body.password, password_hash_for_timing(user))
if not user or not user.get("is_active") or not password_ok:
record_failed_login(body.username)
raise HTTPException(status_code=401, detail="Invalid username or password")
clear_failed_login(body.username)
token = create_session(user["id"])
return {
"token": token,
"user": {
"id": user["id"],
"username": user["username"],
"role": user["role"],
"force_password_change": bool(user["force_password_change"]),
},
}
@app.post("/api/auth/logout")
def logout(request: Request):
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
if token:
delete_session(token)
return {"ok": True}
@app.get("/api/auth/me")
def get_me(request: Request):
if request.state.user_id is None:
return {"id": None, "username": "admin", "role": "admin", "force_password_change": False}
return request.state.user
@app.post("/api/auth/download-token")
def issue_download_token(request: Request, body: DownloadTokenRequest):
"""Issue a short-lived single-use token for downloading one specific job."""
job_id = (body.job_id or "").strip()
if not job_id:
raise HTTPException(status_code=400, detail="job_id is required")
# Single-user mode has no session token in play; direct links are fine.
if request.state.user_id is None:
return {"url": _app_path(f"/api/jobs/{job_id}/download", request), "expires_in": 0}
user_id = request.state.user_id
is_admin = request.state.is_admin
with db_conn() as conn:
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
row = conn.execute(
f"""SELECT 1 FROM jobs
WHERE id = ?
AND status IN ('completed', 'completed_with_errors')
AND COALESCE(file_deleted, 0) = 0
AND {_scope_frag}
LIMIT 1""",
(job_id, *_scope_params),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Job not found")
token = create_download_token(user_id, job_id)
return {
"url": _app_path(f"/api/jobs/{job_id}/download?download_token={token}", request),
"expires_in": DOWNLOAD_TOKEN_TTL_SECONDS,
}
@app.post("/api/auth/stream-token")
def issue_stream_token(request: Request):
"""Issue a short-lived reusable token so an <audio> element can play a file.
The browser cannot bolt an Authorization header onto `audio.src`, so without
this every play button gets a cheerful 401 and the UI mutters "Could not play
file". The token says who is asking and nothing more; the stream endpoints
still scope what they will serve.
"""
# Single-user mode has no session token in play; the endpoints are open anyway.
if request.state.user_id is None:
return {"token": "", "expires_in": 0}
token = create_stream_token(request.state.user_id)
return {"token": token, "expires_in": STREAM_TOKEN_TTL_SECONDS}
@app.put("/api/auth/password")
def change_own_password(request: Request, body: ChangePasswordRequest):
if request.state.user_id is None:
raise HTTPException(status_code=400, detail="Password change not available in single-user mode")
user = get_user_by_id(request.state.user_id)
if not user or not verify_password(body.current_password, user["password_hash"]):
raise HTTPException(status_code=401, detail="Current password incorrect")
if not body.new_password or len(body.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
current_token = None
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
current_token = auth_header[7:].strip() or None
update_password(request.state.user_id, body.new_password, keep_session_token=current_token)
return {"ok": True}
@app.get("/api/users")
def get_users(request: Request):
if not request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
return {"users": list_users()}
@app.post("/api/users")
def create_new_user(request: Request, body: CreateUserRequest):
if not request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
if body.role not in ("admin", "user", "peon"):
raise HTTPException(status_code=400, detail="Role must be 'admin', 'user', or 'peon'")
if not body.username:
raise HTTPException(status_code=400, detail="Username cannot be empty")
if not body.password or len(body.password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
# First user must be admin; they inherit the single-user instance
with db_conn() as conn:
user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if user_count == 0 and body.role != "admin":
raise HTTPException(status_code=400, detail="The first account must be an admin")
first_user = user_count == 0
# Adding the 2nd account flips us from single-user to session-required mode.
# The current admin has no session, so subsequent requests will 401; the
# frontend uses this flag to bounce them to login instead of leaving the
# user list stuck on the old single-user view.
crosses_into_session_mode = user_count == 1
try:
new_id = create_user(body.username, body.password, body.role)
except ValueError:
raise HTTPException(status_code=409, detail="Username already exists")
invalidate_users_cache()
return {
"ok": True,
"user_id": new_id,
"first_user": first_user,
"requires_login": crosses_into_session_mode,
}
@app.delete("/api/users/{user_id}")
def remove_user(request: Request, user_id: str):
if not request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
if user_id == request.state.user_id:
raise HTTPException(status_code=400, detail="Cannot delete your own account")
if not get_user_by_id(user_id):
raise HTTPException(status_code=404, detail="User not found")
delete_user(user_id)
invalidate_users_cache()
return {"ok": True}
@app.put("/api/users/{user_id}/password")
def admin_set_password(request: Request, user_id: str, body: SetUserPasswordRequest):
if not request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
if not get_user_by_id(user_id):
raise HTTPException(status_code=404, detail="User not found")
if not body.new_password or len(body.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
update_password(user_id, body.new_password)
return {"ok": True}
@app.put("/api/users/{user_id}/role")
def set_user_role(request: Request, user_id: str, body: SetUserRoleRequest):
if not request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
if body.role not in ("admin", "user", "peon"):
raise HTTPException(status_code=400, detail="Role must be 'admin', 'user', or 'peon'")
if not get_user_by_id(user_id):
raise HTTPException(status_code=404, detail="User not found")
if user_id == request.state.user_id:
raise HTTPException(status_code=400, detail="Cannot change your own role")
with db_conn() as conn:
conn.execute("UPDATE users SET role = ? WHERE id = ?", (body.role, user_id))
conn.commit()
return {"ok": True}
@app.put("/api/users/{user_id}/force-password-change")
def flag_password_reset(request: Request, user_id: str):
"""Flag a user's account so they must set a new password on next login."""
if not request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
if user_id == request.state.user_id:
raise HTTPException(status_code=400, detail="Cannot force-reset your own password")
if not get_user_by_id(user_id):
raise HTTPException(status_code=404, detail="User not found")
with db_conn() as conn:
conn.execute("UPDATE users SET force_password_change = 1 WHERE id = ?", (user_id,))
# Also kill all their active sessions; they'll need to log in fresh and change immediately
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
conn.commit()
return {"ok": True}
# =============================================================================
# Settings API
# =============================================================================
def _clear_spotify_cookies_expired(user_id: str | None) -> None:
"""Reset the expired flag after fresh Spotify cookies are saved."""
if user_id:
set_user_setting(user_id, "spotify_cookies_expired", "false")
else:
set_setting("spotify_cookies_expired", "false")
@app.get("/api/settings")
def get_settings(request: Request):
"""Get settings for the current user.
Admins get the full schema (global settings with their user_settings merged on top).
Regular users get only their user-scoped keys, falling back to the global value.
"""
settings = {}
env_overrides = []
# Determine which keys to expose for this request.
# Admins see everything; regular users see only their own slice.
if request.state.is_admin:
keys_to_return = SETTINGS_SCHEMA.keys()
else:
keys_to_return = USER_SETTINGS_KEYS
user_id = request.state.user_id
for key in keys_to_return:
schema = SETTINGS_SCHEMA.get(key, {"type": "str", "default": "", "sensitive": False})
# Use user_id so per-user overrides are applied where relevant
value = _get_typed_setting(key, user_id=user_id)
is_sensitive = schema.get("sensitive", False)
# Only expose env-override information for keys the caller can see
if _is_env_override(key):
env_overrides.append(key)
# Mask sensitive values (show that something is set, but not what)
if is_sensitive and value:
settings[key] = "••••••••"
else:
settings[key] = value
return {
"settings": settings,
"env_overrides": env_overrides, # Frontend can disable these fields
"sensitive_fields": list(SENSITIVE_SETTINGS)
}
@app.put("/api/settings")
def update_settings(updates: SettingsUpdate, request: Request):
"""Update settings. Only non-None values are updated. Returns updated settings.
Per-user keys are written to user_settings (or global when in single-user mode).
Global keys can only be written by admins. Peons are read-only; they inherit
the admin's globals for everything and have no Settings UI to begin with.
"""
# Peons have no business writing settings; Settings tab is hidden, but the
# API endpoint stays open to other roles, so this is the belt to the UI's braces.
user = getattr(request.state, "user", None) or {}
if user.get("role") == "peon":
raise HTTPException(status_code=403, detail="Read-only account")
updated_keys = []
user_id = request.state.user_id
for key, value in updates.model_dump(exclude_none=True).items():
if key not in SETTINGS_SCHEMA:
continue
# Env-var overrides are immutable; skip silently
if _is_env_override(key):
continue
# Convert booleans to string for storage
if isinstance(value, bool):
value = "true" if value else "false"
else:
value = str(value)
# Validate subfolder settings to keep writes under MUSIC_DIR.
if key in ("singles_subdir", "playlists_subdir", "albums_subdir"):
raw = value.strip().replace("\\", "/")
if raw == "." or raw == "":
value = raw
else:
parts = [
part.strip()
for part in raw.split("/")
if part.strip() and part.strip() != "."
]
if any(part == ".." for part in parts):
raise HTTPException(status_code=400, detail=f"Invalid {key.replace('_', ' ')} path")
default_subdir = {
"singles_subdir": "Singles",
"playlists_subdir": "Playlists",
"albums_subdir": "Albums",
}[key]
value = "/".join(parts) or default_subdir
try:
configured_music_dir = Path(get_setting("music_dir", str(MUSIC_DIR)))
(configured_music_dir / value).resolve().relative_to(configured_music_dir.resolve())
except ValueError:
raise HTTPException(status_code=400, detail=f"{key.replace('_', ' ')} must stay within music directory")
# Only allow sensible chmod values; a free-text octal field is a footgun
if key == "file_permissions" and value not in ("666", "777"):
raise HTTPException(status_code=400, detail="file_permissions must be 666 or 777")
if key == "search_concurrency" and not 1 <= int(value) <= 5:
raise HTTPException(status_code=400, detail="search_concurrency must be between 1 and 5")
# Validate cookie format before saving
if key == "youtube_cookies" and value.strip() and not _has_valid_cookie_entries(value):
raise HTTPException(
status_code=400,
detail="Invalid cookies format. Paste Netscape-format cookies.txt content."
)
if key == "spotify_cookies" and value.strip():
if not _has_valid_cookie_entries(value):
raise HTTPException(
status_code=400,
detail="Invalid cookies format. Paste Netscape-format cookies.txt content."
)
lines = _cookie_lines_for_domain_check(value)
has_sp_dc = any(
len(l.split("\t")) >= 7 and l.split("\t")[5] == "sp_dc"
for l in lines if not l.startswith("#")
)
if not has_sp_dc:
raise HTTPException(
status_code=400,
detail="No sp_dc cookie found. Export cookies from open.spotify.com while logged in."
)
# Route the write: user-scoped keys go to user_settings (or global in single-user mode);
# global keys require admin privileges.
if key in USER_SETTINGS_KEYS:
if user_id is None:
# Single-user mode: no user table row, fall back to global store
set_setting(key, value)
else:
set_user_setting(user_id, key, value)
else:
# Global setting: only admins may touch these
if not request.state.is_admin:
continue
set_setting(key, value)
updated_keys.append(key)
# Sync cookies file if YouTube cookies were updated. Scope it to the user we
# just saved for: unscoped, the save lands in user_settings while the file
# written is the global one, which on a multi-user box either deletes
# cookies.txt or leaves everyone sharing one account (issue #95).
if "youtube_cookies" in updated_keys:
_sync_cookies_file(user_id=user_id)
# Clear the expired flag when fresh Spotify cookies are saved
if "spotify_cookies" in updated_keys:
_clear_spotify_cookies_expired(user_id)
return {
"updated": updated_keys,
"settings": get_settings(request)["settings"]
}
def _maintenance_orphan_import_ids(conn: sqlite3.Connection) -> list[str]:
return [
row[0] for row in conn.execute(
"""SELECT bi.id
FROM bulk_imports bi
LEFT JOIN watched_playlists wp ON wp.id = bi.watch_playlist_id
LEFT JOIN watched_artists wa ON wa.id = bi.watch_artist_id
WHERE (bi.watch_playlist_id IS NOT NULL AND wp.id IS NULL)
OR (bi.watch_artist_id IS NOT NULL AND wa.id IS NULL)"""
).fetchall()
]
@app.get("/api/settings/maintenance")
def maintenance_preview(http_request: Request):
"""Preview database maintenance without changing anything."""
if not http_request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
with db_conn() as conn:
orphan_ids = _maintenance_orphan_import_ids(conn)
counts = {
status: conn.execute(
"SELECT COUNT(*) FROM jobs WHERE status = ?", (status,)
).fetchone()[0]
for status in ("completed", "completed_with_errors", "failed", "cancelled")
}
indexed_paths = conn.execute(
"SELECT COUNT(*) FROM jobs WHERE final_path IS NOT NULL AND final_path != ''"
).fetchone()[0]
return {
"orphan_imports": len(orphan_ids),
"queue_history": counts,
"indexed_paths": indexed_paths,
}
def _purge_orphan_imports(conn: sqlite3.Connection) -> int:
orphan_ids = _maintenance_orphan_import_ids(conn)
for import_id in orphan_ids:
target_ids = [
row[0] for row in conn.execute(
"SELECT acquisition_target_id FROM bulk_import_tracks WHERE import_id = ? AND acquisition_target_id IS NOT NULL",
(import_id,),
).fetchall()
]
conn.execute("DELETE FROM bulk_import_tracks WHERE import_id = ?", (import_id,))
conn.execute("DELETE FROM bulk_imports WHERE id = ?", (import_id,))
conn.executemany(
"DELETE FROM acquisition_targets WHERE id = ?",
[(target_id,) for target_id in target_ids],
)
return len(orphan_ids)
def _rebuild_library_index() -> int:
"""Add history rows for audio under configured roots which lacks an exact path."""
roots = [get_singles_dir(), get_playlists_dir(), get_albums_dir()]
with db_conn() as conn:
known = {
row[0] for row in conn.execute(
"SELECT final_path FROM jobs WHERE final_path IS NOT NULL AND final_path != ''"
).fetchall()
}
rebuilt = []
seen = set()
for root in roots:
if root is None or not root.exists():
continue
# The index already holds every audio file under this root, so an admin
# rebuild no longer drags the entire library across the mount to be told
# what it wrote down yesterday. A root it cannot vouch for falls back to
# the walk: "I saw nothing" and "I looked at nothing" must never be the
# same answer when the result becomes rows in the jobs table.
audio_paths = library_index.paths_under(root)
if audio_paths is None:
audio_paths = [p for p, _st in iter_library_audio_files(root, AUDIO_EXTENSIONS)]
# `final_path` is written resolved, so a library reached through a symlink
# has to be compared resolved or every file already in the history gets a
# second row. One resolve of the root does the whole job: no scan descends
# a symlinked directory, so nothing beneath it can resolve anywhere else.
try:
base = root.resolve()
except OSError:
base = root
for audio_path in audio_paths:
try:
relative = audio_path.relative_to(root)
artist = relative.parts[-2] if len(relative.parts) > 1 else "Unknown"
resolved = str(base / relative)
except ValueError:
artist = "Unknown"
resolved = str(audio_path)
if resolved in known or resolved in seen:
continue
seen.add(resolved)
# Full UUID here, not the usual 8-char job ID. A rebuild can mint tens
# of thousands of rows in one batch, and 32 bits of birthday paradox
# says a collision around 50k is a coin toss - which would abort the
# whole insert. Nobody types these IDs, so the extra characters cost us
# nothing.
rebuilt.append((str(uuid.uuid4()), audio_path.stem, artist, resolved))
if rebuilt:
with db_conn() as conn:
conn.executemany(
"""INSERT INTO jobs
(id, title, artist, status, download_type, source, final_path,
file_deleted, created_at, completed_at)
VALUES (?, ?, ?, 'completed', 'single', 'library_rebuild', ?, 0,
datetime('now'), datetime('now'))""",
rebuilt,
)
conn.commit()
return len(rebuilt)
@app.post("/api/settings/maintenance")
def run_maintenance(body: MaintenanceRequest, http_request: Request):
"""Run an explicitly confirmed, audio-safe maintenance action."""
if not http_request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
if not body.confirm:
raise HTTPException(status_code=400, detail="Confirmation required")
if body.action == "purge_orphans":
with db_conn() as conn:
changed = _purge_orphan_imports(conn)
conn.commit()
return {"orphan_imports_removed": changed}
if body.action == "clear_history":
allowed = {"completed", "completed_with_errors", "failed", "cancelled"}
statuses = set(body.statuses) & allowed
if not statuses:
raise HTTPException(status_code=400, detail="Select at least one history status")
placeholders = ",".join("?" for _ in statuses)
with db_conn() as conn:
changed = conn.execute(
f"DELETE FROM jobs WHERE status IN ({placeholders})", tuple(sorted(statuses))
).rowcount
conn.commit()
return {"queue_rows_removed": changed}
if body.action in {"reconcile", "clean_rebuild"}:
marked_deleted, unlinked = reconcile_deleted_library_files(batch_size=1_000_000)
orphan_count = 0
rebuilt = 0
if body.action == "clean_rebuild":
with db_conn() as conn:
orphan_count = _purge_orphan_imports(conn)
conn.commit()
rebuilt = _rebuild_library_index()
return {
"jobs_marked_deleted": marked_deleted,
"watched_rows_unlinked": unlinked,
"orphan_imports_removed": orphan_count,
"library_rows_rebuilt": rebuilt,
}
raise HTTPException(status_code=400, detail="Unknown maintenance action")
# =============================================================================
# Settings Test Endpoints
# =============================================================================
@app.post("/api/settings/test/slskd")
def test_slskd_connection(http_request: Request, body: TestSlskdRequest = None):
"""Test connection to slskd server. Uses form values if provided, otherwise saved settings."""
if not http_request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
url = (body.url if body and body.url else None) or _get_typed_setting("slskd_url")
user = (body.username if body and body.username else None) or _get_typed_setting("slskd_user")
password = (body.password if body and body.password else None) or _get_typed_setting("slskd_pass")
downloads_path = (body.downloads_path if body and body.downloads_path else None) or _get_typed_setting("slskd_downloads_path")
if not url:
return {"success": False, "message": "slskd URL not configured"}
try:
with httpx.Client(timeout=10) as client:
auth_response = client.post(
f"{url.rstrip('/')}/api/v0/session",
json={"username": user, "password": password}
)
if auth_response.status_code == 200:
if not downloads_path:
return {
"success": True,
"warning": True,
"message": "Connected to slskd. Search works. For downloads to import, slskd and MusicGrabber must point at the same folder on your server: mount slskd's completed-downloads directory into MusicGrabber at the same path, then enter that path in the field below. See the README for a step-by-step example.",
}
path = Path(downloads_path).expanduser()
if not path.exists():
return {
"success": True,
"warning": True,
"message": f"Connected to slskd, but downloads path is not visible to MusicGrabber: {downloads_path}",
}
if not path.is_dir():
return {
"success": True,
"warning": True,
"message": f"Connected to slskd, but downloads path is not a directory: {downloads_path}",
}
return {"success": True, "message": "Connected to slskd and downloads path is accessible"}
else:
return {"success": False, "message": f"Authentication failed: {auth_response.status_code}"}
except httpx.TimeoutException:
return {"success": False, "message": "Connection timed out"}
except Exception as e:
print(f"slskd connection test error: {type(e).__name__}: {e}")
return {"success": False, "message": "Connection failed - check server logs for details"}
@app.post("/api/settings/test/navidrome")
def test_navidrome_connection(http_request: Request, body: TestNavidromeRequest = None):
"""Test connection to Navidrome server. Uses form values if provided, otherwise saved settings."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
# Non-admins can only test their saved settings, not probe arbitrary URLs
url = (body.url if body and body.url and is_admin else None) or _get_typed_setting("navidrome_url", user_id=user_id)
user = (body.username if body and body.username and is_admin else None) or _get_typed_setting("navidrome_user", user_id=user_id)
password = (body.password if body and body.password and is_admin else None) or _get_typed_setting("navidrome_pass", user_id=user_id)
if not url:
return {"success": False, "message": "Navidrome URL not configured"}
try:
params = subsonic_auth_params(user, password)
with httpx.Client(timeout=10) as client:
response = client.get(
f"{url.rstrip('/')}/rest/ping",
params=params
)
if response.status_code != 200:
return {"success": False, "message": f"Connection failed: {response.status_code}"}
data = response.json()
if data.get("subsonic-response", {}).get("status") != "ok":
return {"success": False, "message": "Authentication failed"}
# Ping succeeded - now check and auto-enable real paths via Navidrome's native API.
# Navidrome returns synthetic paths by default (Artist/Album/Track.mp3), which are
# useless for M3U playlist entries. We auto-flip any MusicGrabber player records we
# find that have reportRealPath=false so users don't have to do it manually.
real_path_enabled = False
real_path_auto_fixed = False
try:
auth_resp = client.post(
f"{url.rstrip('/')}/auth/login",
json={"username": user, "password": password}
)
if auth_resp.status_code == 200:
token = auth_resp.json().get("token")
if token:
nd_headers = {"X-ND-Authorization": f"Bearer {token}"}
players_resp = client.get(
f"{url.rstrip('/')}/api/player",
headers=nd_headers
)
if players_resp.status_code == 200:
for player in players_resp.json():
if "musicgrabber" not in (player.get("client") or "").lower():
continue
if player.get("reportRealPath"):
real_path_enabled = True
else:
# Auto-enable real paths for this player
updated = {**player, "reportRealPath": True}
put_resp = client.put(
f"{url.rstrip('/')}/api/player/{player['id']}",
json=updated,
headers=nd_headers
)
if put_resp.status_code == 200:
real_path_enabled = True
real_path_auto_fixed = True
except Exception as e:
print(f"Navidrome real-path check failed: {type(e).__name__}: {e}") # Non-critical
if real_path_enabled:
msg = ("Connected to Navidrome successfully. Real file paths auto-enabled for MusicGrabber."
if real_path_auto_fixed else "Connected to Navidrome successfully")
return {
"success": True,
"message": msg,
"real_path": True
}
return {
"success": True,
"message": "Connected to Navidrome successfully",
"real_path": False,
"real_path_hint": (
"Real file paths could not be enabled automatically. Without them, Navidrome "
"duplicate detection still works but M3U playlist entries cannot be populated "
"for Navidrome-only tracks. To fix this manually, add "
"ND_SUBSONIC_DEFAULTREPORTREALPATH=true to your Navidrome docker-compose "
"environment, or enable \"Report real path\" for MusicGrabber in "
"Navidrome admin > Players."
)
}
except httpx.TimeoutException:
return {"success": False, "message": "Connection timed out"}
except Exception as e:
print(f"Navidrome connection test error: {type(e).__name__}: {e}")
return {"success": False, "message": "Connection failed - check server logs for details"}
@app.post("/api/settings/test/jellyfin")
def test_jellyfin_connection(http_request: Request, body: TestJellyfinRequest = None):
"""Test connection to Jellyfin server. Uses form values if provided, otherwise saved settings."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
# Non-admins can only test their saved settings, not probe arbitrary URLs
url = (body.url if body and body.url and is_admin else None) or _get_typed_setting("jellyfin_url", user_id=user_id)
api_key = (body.api_key if body and body.api_key and is_admin else None) or _get_typed_setting("jellyfin_api_key", user_id=user_id)
if not url:
return {"success": False, "message": "Jellyfin URL not configured"}
if not api_key:
return {"success": False, "message": "Jellyfin API key not configured"}
try:
with httpx.Client(timeout=10) as client:
response = client.get(
f"{url.rstrip('/')}/System/Info",
headers=jellyfin_auth_headers(api_key)
)
if response.status_code == 200:
data = response.json()
server_name = data.get("ServerName", "Jellyfin")
return {"success": True, "message": f"Connected to {server_name} successfully"}
elif response.status_code == 401:
return {"success": False, "message": "Invalid API key"}
else:
return {"success": False, "message": f"Connection failed: {response.status_code}"}
except httpx.TimeoutException:
return {"success": False, "message": "Connection timed out"}
except Exception as e:
print(f"Jellyfin connection test error: {type(e).__name__}: {e}")
return {"success": False, "message": "Connection failed - check server logs for details"}
@app.post("/api/settings/test/plex")
def test_plex_connection(http_request: Request, body: TestPlexRequest = None):
"""Test connection to Plex server. Uses form values if provided, otherwise saved settings."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
# Non-admins can only test their saved settings, not probe arbitrary URLs
url = (body.url if body and body.url and is_admin else None) or _get_typed_setting("plex_url", user_id=user_id)
token = (body.token if body and body.token and is_admin else None) or _get_typed_setting("plex_token", user_id=user_id)
if not url:
return {"success": False, "message": "Plex URL not configured"}
if not token:
return {"success": False, "message": "Plex token not configured"}
try:
with httpx.Client(timeout=10) as client:
response = client.get(
f"{url.rstrip('/')}/library/sections",
headers={"X-Plex-Token": token, "Accept": "application/json"}
)
if response.status_code == 200:
sections = response.json().get("MediaContainer", {}).get("Directory", [])
music_sections = [s.get("title", "Music") for s in sections if s.get("type") == "artist"]
if music_sections:
plural = "y" if len(music_sections) == 1 else "ies"
return {
"success": True,
"message": f"Connected to Plex - found {len(music_sections)} music librar{plural}: {', '.join(music_sections)}"
}
return {
"success": True,
"message": "Connected to Plex, but no music library was found. Rescans will have nothing to refresh until one exists.",
"warning": True,
}
elif response.status_code == 401:
return {"success": False, "message": "Invalid Plex token"}
else:
return {"success": False, "message": f"Connection failed: {response.status_code}"}
except httpx.TimeoutException:
return {"success": False, "message": "Connection timed out"}
except Exception as e:
print(f"Plex connection test error: {type(e).__name__}: {e}")
return {"success": False, "message": "Connection failed - check server logs for details"}
@app.post("/api/settings/test/lidarr")
def test_lidarr_connection(http_request: Request, body: TestLidarrRequest = None):
"""Test connection to Lidarr. Uses form values if provided, otherwise saved settings."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
# Non-admins can only test their saved settings, not probe arbitrary URLs
url = (body.url if body and body.url and is_admin else None) or _get_typed_setting("lidarr_url", user_id=user_id)
api_key = (body.api_key if body and body.api_key and is_admin else None) or _get_typed_setting("lidarr_api_key", user_id=user_id)
if not url:
return {"success": False, "message": "Lidarr URL not configured"}
if not api_key:
return {"success": False, "message": "Lidarr API key not configured"}
try:
with httpx.Client(timeout=10) as client:
response = client.get(
f"{url.rstrip('/')}/api/v1/system/status",
headers={"X-Api-Key": api_key}
)
if response.status_code == 200:
data = response.json()
version = data.get("version", "unknown")
artist_resp = client.get(
f"{url.rstrip('/')}/api/v1/artist",
headers={"X-Api-Key": api_key}
)
artist_count = len(artist_resp.json()) if artist_resp.status_code == 200 else 0
return {"success": True, "message": f"Connected to Lidarr v{version} ({artist_count} artists)"}
elif response.status_code == 401:
return {"success": False, "message": "Invalid API key"}
else:
return {"success": False, "message": f"Connection failed: {response.status_code}"}
except httpx.TimeoutException:
return {"success": False, "message": "Connection timed out"}
except Exception as e:
print(f"Lidarr connection test error: {type(e).__name__}: {e}")
return {"success": False, "message": "Connection failed - check server logs for details"}
@app.post("/api/settings/test/monochrome")
def test_monochrome_downloads(http_request: Request):
"""Run the full Monochrome download diagnostic and report it step by step.
Deliberately end-to-end: it searches, resolves through every leg, downloads
a real track to a temp file, probes the bytes, then bins the lot. Slow, but
the only way to answer "does Monochrome actually work here?" honestly.
Tests the SAVED settings, not whatever is currently typed into the form, so
save before testing. The hifi-api URL and fallback toggles are read deep
inside the resolution ladder; smuggling unsaved values down there would mean
threading overrides through a dozen call sites for very little gain.
"""
if not http_request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
from monochrome import run_download_diagnostic
return run_download_diagnostic()
@app.post("/api/settings/test/youtube-cookies")
def test_youtube_cookies(http_request: Request, body: TestYouTubeCookiesRequest = None):
"""Test YouTube cookies by fetching info for a known public video.
Uses form value if provided, otherwise the saved cookies."""
user_id = http_request.state.user_id
cookies_text = (body.cookies if body and body.cookies else None)
if cookies_text is None:
cookies_text = get_setting("youtube_cookies", "", user_id=user_id)
if not cookies_text.strip():
return {"success": False, "message": "No cookies provided"}
# Basic format validation
if not _has_valid_cookie_entries(cookies_text):
return {"success": False, "message": "No cookie entries found (only comments or blank lines)"}
lines = _cookie_lines_for_domain_check(cookies_text)
has_youtube_cookie = any(".youtube.com" in l or ".google.com" in l for l in lines)
if not has_youtube_cookie:
return {"success": False, "message": "No YouTube or Google cookies found. Export cookies from youtube.com."}
# Write to a temp file and test with yt-dlp
tmp_path = None
try:
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write(cookies_text)
tmp_path = f.name
# Use the YouTube Music Charts page as the test target. It's public, globally
# available, and yt-dlp extracts it as a flat playlist - so we get real output
# to confirm yt-dlp + cookies are working together without needing age-restricted
# content or account-specific URLs. If cookies are broken they'll cause a 403 here
# too; if they're fine, we get tracks back and can report success confidently.
# A plain public video would also work but gives no way to distinguish "cookies
# loaded fine" from "cookies were silently ignored".
TEST_URL = "https://music.youtube.com/playlist?list=PLFgquLnL59alCl_2TQvOiD5Vgm1hCaGSI"
test_cmd = [
"yt-dlp",
"--cookies", tmp_path,
"--flat-playlist",
"--dump-json",
"--no-warnings",
"--playlist-items", "1",
TEST_URL,
]
result = subprocess.run(test_cmd, capture_output=True, text=True, timeout=TIMEOUT_YTDLP_INFO)
stderr = result.stderr.lower()
if result.returncode == 0 and result.stdout.strip():
return {"success": True, "message": "Cookies loaded and working"}
if _is_ytdlp_403(result.stderr):
return {"success": False, "message": "Cookies rejected by YouTube (403). They may be expired - try re-exporting."}
if "sign in" in stderr or "login" in stderr or "not logged in" in stderr:
return {"success": False, "message": "Cookies rejected - YouTube says you're not logged in. Re-export cookies while logged in."}
# Test playlist unavailable or yt-dlp returned nothing - not necessarily a cookie problem
print(f"Cookie test: test URL failed or returned no output. stderr={result.stderr[:300]!r}")
return {"success": True, "message": "Cookies loaded (test inconclusive - real-world auth untested)"}
except subprocess.TimeoutExpired:
return {"success": False, "message": "Test timed out"}
except Exception as e:
print(f"YouTube cookie test error: {type(e).__name__}: {e}")
return {"success": False, "message": "Cookie test failed - check server logs for details"}
finally:
if tmp_path:
Path(tmp_path).unlink(missing_ok=True)
@app.post("/api/settings/test/spotify-cookies")
def test_spotify_cookies(http_request: Request, body: TestSpotifyCookiesRequest = None):
"""Test Spotify cookies by hitting a known private-friendly endpoint.
Uses the form value if provided, otherwise the saved cookies.
"""
user_id = http_request.state.user_id
cookies_text = (body.cookies if body and body.cookies else None)
if cookies_text is None:
cookies_text = get_setting("spotify_cookies", "", user_id=user_id)
if not cookies_text.strip():
return {"success": False, "message": "No cookies provided"}
if not _has_valid_cookie_entries(cookies_text):
return {"success": False, "message": "No cookie entries found (only comments or blank lines)"}
lines = _cookie_lines_for_domain_check(cookies_text)
sp_dc = next(
(l.split("\t")[6].strip() for l in lines
if not l.startswith("#") and len(l.split("\t")) >= 7 and l.split("\t")[5] == "sp_dc"),
None
)
if not sp_dc:
return {"success": False, "message": "No sp_dc cookie found. Export cookies from open.spotify.com while logged in."}
# Test by fetching a small known public playlist embed with the cookie.
# The embed endpoint is the same one used for real fetches, so if it works
# here it'll work everywhere. Spotify's Varnish CDN blocks the internal
# token exchange endpoint from non-browser origins, so the embed is the
# reliable way to confirm the session is alive from a server context.
# "Top 50 - Global" is stable, public, and always has tracks in the JSON blob.
TEST_PLAYLIST_ID = "37i9dQZEVXbMDoHDwVN2tF"
try:
with httpx.Client(timeout=15, follow_redirects=True) as client:
resp = client.get(
f"https://open.spotify.com/embed/playlist/{TEST_PLAYLIST_ID}",
cookies={"sp_dc": sp_dc},
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
}
)
if resp.status_code in (401, 403):
return {"success": False, "message": "Cookies rejected by Spotify; they may have expired. Re-export from open.spotify.com."}
if resp.status_code != 200:
return {"success": False, "message": f"Spotify returned {resp.status_code}; cookies may be invalid"}
# If the embed returned track data, the session is working
import re as _re
titles = _re.findall(r'"title":"([^"]+)"', resp.text)
if len(titles) > 1:
return {"success": True, "message": "Spotify cookies are valid and authenticated"}
return {"success": False, "message": "Cookies loaded but Spotify returned no track data; they may be expired"}
except Exception as e:
print(f"Spotify cookie test error: {type(e).__name__}: {e}")
return {"success": False, "message": "Cookie test failed; check server logs for details"}
@app.post("/api/settings/test/apprise")
def test_apprise_notification(http_request: Request, body: TestAppriseRequest = None):
"""Send a test notification via Apprise. Uses form URL if provided, otherwise saved setting."""
user_id = http_request.state.user_id
url = (body.url if body and body.url else None) or get_setting("apprise_url", "", user_id=user_id)
if not url:
return {"success": False, "message": "Apprise URL not configured"}
try:
import apprise
a = apprise.Apprise()
if not a.add(url):
return {"success": False, "message": "Invalid Apprise URL - could not parse the notification service"}
result = a.notify(title="MusicGrabber", body="Test notification from MusicGrabber. If you can see this, it works!")
if result:
return {"success": True, "message": "Test notification sent successfully"}
return {"success": False, "message": "Apprise returned failure - check the URL and service configuration"}
except ImportError:
return {"success": False, "message": "Apprise library not installed - rebuild the Docker image"}
except Exception as e:
print(f"Apprise test error: {type(e).__name__}: {e}")
return {"success": False, "message": "Notification failed - check server logs for details"}
@app.post("/api/settings/test/email")
def test_email_notification(http_request: Request, body: TestEmailRequest = None):
"""Send a test email using current form values and saved secret fallbacks."""
user_id = http_request.state.user_id
def supplied_or_saved(key: str):
supplied = getattr(body, key, None) if body else None
return supplied if supplied is not None else _get_typed_setting(key, user_id=user_id)
success, message = send_test_email(
smtp_host=supplied_or_saved("smtp_host"),
smtp_port=supplied_or_saved("smtp_port"),
smtp_user=supplied_or_saved("smtp_user"),
smtp_pass=supplied_or_saved("smtp_pass"),
smtp_from=supplied_or_saved("smtp_from"),
smtp_to=supplied_or_saved("smtp_to"),
smtp_tls=supplied_or_saved("smtp_tls"),
)
return {"success": success, "message": message}
@app.get("/api/settings/youtube-cookies/status")
def youtube_cookies_status(http_request: Request):
"""Return non-sensitive status for the cookies file."""
user_id = http_request.state.user_id
cookies_text = get_setting("youtube_cookies", "", user_id=user_id)
has_setting = bool(cookies_text.strip())
file_exists = COOKIES_FILE.exists()
file_size = COOKIES_FILE.stat().st_size if file_exists else 0
file_mtime = COOKIES_FILE.stat().st_mtime if file_exists else None
expiry = get_cookies_expiry(cookies_text) if has_setting else None
return {
"has_setting": has_setting,
"file_exists": file_exists,
"file_size": file_size,
"file_mtime": file_mtime,
"file_has_valid_entries": _has_valid_cookie_entries(cookies_text) if has_setting else False,
"auth_cookie_expiry": expiry, # Unix timestamp of soonest auth cookie expiry, or null
}
# =============================================================================
# Statistics API
# =============================================================================
def _extract_search_artist(query: str) -> str | None:
"""Best-effort artist extraction from common search query formats."""
q = (query or "").strip()
if not q:
return None
# "Artist - Song", "Artist – Song", "Artist - Song"
split_match = re.split(r"\s*[-–—]\s*", q, maxsplit=1)
if len(split_match) == 2 and split_match[0].strip():
return split_match[0].strip()[:120]
# Fallback: first 3 words is usually artist-ish, avoids giant free text blobs
words = q.split()
if not words:
return None
return " ".join(words[:3])[:120]
def _log_search(query: str, result_count: int, source: str = "youtube", user_id: str | None = None) -> str:
"""Log search requests for dashboard analytics and return tracking token."""
artist = _extract_search_artist(query)
search_token = uuid.uuid4().hex
with db_conn() as conn:
conn.execute(
"INSERT INTO search_logs (query, artist, result_count, source, search_token, user_id) VALUES (?, ?, ?, ?, ?, ?)",
(query.strip(), artist, int(result_count), source, search_token, user_id)
)
conn.commit()
return search_token
def _validated_search_token(search_token: str | None, user_id: str | None = None) -> str | None:
"""Only accept server-issued search tokens that exist in search_logs, scoped to the user."""
token = (search_token or "").strip().lower()
if not token or not re.fullmatch(r"[0-9a-f]{32}", token):
return None
with db_conn() as conn:
if user_id is not None:
row = conn.execute(
"SELECT 1 FROM search_logs WHERE search_token = ? AND user_id = ? LIMIT 1",
(token, user_id)
).fetchone()
else:
row = conn.execute(
"SELECT 1 FROM search_logs WHERE search_token = ? LIMIT 1",
(token,)
).fetchone()
return token if row else None
def _search_duplicate_notice(query: str, user_id: str | None = None) -> dict | None:
"""Return a best-effort library duplicate notice for Artist - Title searches."""
artist, title = _parse_query_artist_title(query)
if not artist or not title:
return None
matches = []
nav_path = check_navidrome_duplicate(artist, title, user_id=user_id)
if nav_path:
matches.append({"source": "Navidrome", "path": str(nav_path)})
lidarr_path = check_lidarr_duplicate(artist, title, user_id=user_id)
if lidarr_path:
matches.append({"source": "Lidarr", "path": str(lidarr_path)})
if not matches:
return None
sources = [m["source"] for m in matches]
source_text = sources[0] if len(sources) == 1 else ", ".join(sources[:-1]) + f" and {sources[-1]}"
return {
"artist": artist,
"title": title,
"sources": matches,
"message": (
f"{artist} - {title} already appears to be in {source_text}. "
"You can still download it again if you want another copy."
),
}
@app.get("/api/stats")
@prioritise_over_audio_audit
def get_stats(http_request: Request):
"""Return download statistics for the dashboard.
Admins see every account, legacy single-user rows included. A standard user
sees only their own jobs and searches; the legacy rows are the admin's
history, not theirs. Peons get no Stats tab, so no Stats either.
"""
is_admin = bool(http_request.state.is_admin)
if _is_peon(http_request):
raise HTTPException(status_code=403, detail="Stats are not available to this account")
# "AND 1" rather than an empty string keeps every query below one shape.
if is_admin:
mine, mine_s, params = "1", "1", ()
else:
mine, mine_s, params = "user_id = ?", "s.user_id = ?", (http_request.state.user_id,)
with db_conn() as conn:
conn.row_factory = sqlite3.Row
# Overall counts by status
rows = conn.execute(
f"SELECT status, COUNT(*) as count FROM jobs WHERE {mine} GROUP BY status", params
).fetchall()
status_counts = {r["status"]: r["count"] for r in rows}
# Total completed tracks
total = sum(status_counts.values())
completed = status_counts.get("completed", 0) + status_counts.get("completed_with_errors", 0)
failed = status_counts.get("failed", 0)
# Source breakdown (youtube vs soulseek)
source_rows = conn.execute(
"SELECT COALESCE(source, 'youtube') as source, COUNT(*) as count "
f"FROM jobs WHERE status IN ('completed', 'completed_with_errors') AND {mine} "
"GROUP BY source", params
).fetchall()
sources = {r["source"]: r["count"] for r in source_rows}
# Downloads completed per day (last 30 days)
daily_rows = conn.execute(
"SELECT DATE(completed_at) as day, COUNT(*) as count "
"FROM jobs WHERE status IN ('completed', 'completed_with_errors') "
"AND completed_at IS NOT NULL "
"AND completed_at >= DATE('now', '-30 days') "
f"AND {mine} "
"GROUP BY day ORDER BY day", params
).fetchall()
daily = [{"day": r["day"], "count": r["count"]} for r in daily_rows]
# Top artists (by completed downloads) - case-insensitive grouping,
# display the most popular casing variant for each artist
artist_rows = conn.execute(
"SELECT artist, total_count as count FROM ("
" SELECT artist, "
" SUM(COUNT(*)) OVER (PARTITION BY LOWER(artist)) as total_count, "
" ROW_NUMBER() OVER (PARTITION BY LOWER(artist) ORDER BY COUNT(*) DESC) as rn "
" FROM jobs "
" WHERE status IN ('completed', 'completed_with_errors') "
" AND artist IS NOT NULL AND artist != '' "
f" AND {mine} "
" GROUP BY artist"
") WHERE rn = 1 "
"ORDER BY count DESC LIMIT 10", params
).fetchall()
top_artists = [{"artist": r["artist"], "count": r["count"]} for r in artist_rows]
# Recent downloads (last 10 completed)
recent_rows = conn.execute(
"SELECT title, artist, source, completed_at "
f"FROM jobs WHERE status IN ('completed', 'completed_with_errors') AND {mine} "
"ORDER BY completed_at DESC LIMIT 10", params
).fetchall()
recent = [
{"title": r["title"], "artist": r["artist"], "source": r["source"], "completed_at": r["completed_at"]}
for r in recent_rows
]
# Search history stats
search_summary = conn.execute(
"SELECT COUNT(*) as total_searches, "
"SUM(CASE WHEN result_count > 0 THEN 1 ELSE 0 END) as successful_searches "
f"FROM search_logs WHERE {mine}", params
).fetchone()
total_searches = int(search_summary["total_searches"] or 0)
successful_searches = int(search_summary["successful_searches"] or 0)
# Case-insensitive grouping - display the most popular casing variant
searched_artist_rows = conn.execute(
"SELECT artist, total_count as count, total_successful as successful_searches FROM ("
" SELECT artist, "
" SUM(COUNT(*)) OVER (PARTITION BY LOWER(artist)) as total_count, "
" SUM(SUM(CASE WHEN result_count > 0 THEN 1 ELSE 0 END)) OVER (PARTITION BY LOWER(artist)) as total_successful, "
" ROW_NUMBER() OVER (PARTITION BY LOWER(artist) ORDER BY COUNT(*) DESC) as rn "
" FROM search_logs "
" WHERE artist IS NOT NULL AND artist != '' "
f" AND {mine} "
" GROUP BY artist"
") WHERE rn = 1 "
"ORDER BY count DESC, artist ASC "
"LIMIT 10", params
).fetchall()
top_searched_artists = [
{
"artist": r["artist"],
"count": int(r["count"]),
"successful_searches": int(r["successful_searches"] or 0),
}
for r in searched_artist_rows
]
search_to_download = conn.execute(
"SELECT COUNT(*) as converted_searches "
"FROM search_logs s "
f"WHERE {mine_s} AND EXISTS ("
" SELECT 1 FROM jobs j "
" WHERE j.search_token = s.search_token "
" AND j.status IN ('completed', 'completed_with_errors')"
")", params
).fetchone()
converted_searches = int(search_to_download["converted_searches"] or 0)
# Release the DB connection before any filesystem work is scheduled. The
# library on disk is everybody's, so only an admin gets its size; a user's
# "own" share of a folder nobody owns is not a number anyone could act on.
storage, scan_state = None, None
if is_admin:
directories = tuple(d for d in (get_singles_dir(), get_playlists_dir()) if d is not None)
storage, scan_state = snapshots.get(
("storage", tuple(map(str, directories))), lambda: scan_storage(directories),
)
return {
"scope": "all" if is_admin else "own",
"total_jobs": total,
"completed": completed,
"failed": failed,
"sources": sources,
"daily": daily,
"top_artists": top_artists,
"total_searches": total_searches,
"successful_searches": successful_searches,
"converted_searches": converted_searches,
"top_searched_artists": top_searched_artists,
"recent": recent,
"storage_bytes": storage["storage_bytes"] if storage is not None else None,
"file_count": storage["file_count"] if storage is not None else None,
"storage_scan_state": scan_state,
}
@app.delete("/api/stats")
def reset_stats(http_request: Request, confirm: bool = False):
"""Reset dashboard stats without touching active queue items."""
if not http_request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
if not confirm:
raise HTTPException(status_code=400, detail="Confirmation required (use ?confirm=true)")
# Keep active work intact, but clear historical job/search data used by stats
cleanup_stale_jobs()
with db_conn() as conn:
deleted_jobs = conn.execute(
"DELETE FROM jobs WHERE status IN ('completed', 'completed_with_errors', 'failed')"
).rowcount
deleted_searches = conn.execute("DELETE FROM search_logs").rowcount
conn.execute("DELETE FROM search_decisions")
conn.commit()
return {"deleted_jobs": deleted_jobs, "deleted_searches": deleted_searches}
@app.get("/api/mismatches")
def get_mismatches(http_request: Request, limit: int = 200):
"""Return recent watched-track match mismatches for investigation."""
if not http_request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
with db_conn() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""SELECT m.id, m.job_id, m.playlist_id, m.expected_artist, m.expected_title,
m.actual_artist, m.actual_title, m.exp_normalised, m.got_normalised,
m.created_at, wp.name AS playlist_name
FROM watched_match_mismatches m
LEFT JOIN watched_playlists wp ON wp.id = m.playlist_id
ORDER BY m.created_at DESC
LIMIT ?""",
(min(limit, 500),),
).fetchall()
return {"mismatches": [dict(r) for r in rows]}
@app.delete("/api/mismatches")
def clear_mismatches(http_request: Request):
"""Clear the mismatch log."""
if not http_request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
with db_conn() as conn:
deleted = conn.execute("DELETE FROM watched_match_mismatches").rowcount
conn.commit()
return {"deleted": deleted}
@app.post("/api/mismatches/{mismatch_id}/accept")
def accept_mismatch(mismatch_id: int, http_request: Request):
"""Force-accept a mismatched track: re-download it but skip the name comparison."""
if not http_request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
with db_conn() as conn:
conn.row_factory = sqlite3.Row
mismatch = conn.execute(
"SELECT * FROM watched_match_mismatches WHERE id = ?", (mismatch_id,)
).fetchone()
if not mismatch:
raise HTTPException(status_code=404, detail="Mismatch not found")
job_id = mismatch["job_id"]
job = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
job = dict(job) if job else None
user_id = (job.get("user_id") if job else None) or http_request.state.user_id
if job:
# Original job still exists, reset it with the skip flag
conn.execute(
"UPDATE jobs SET status = 'queued', error = NULL, completed_at = NULL, "
"file_deleted = 0, skip_mismatch_check = 1, progress_at = datetime('now') "
"WHERE id = ?",
(job_id,)
)
else:
# Job was cleaned up; create a fresh one from the mismatch record
job_id = str(uuid.uuid4())[:8]
artist = mismatch["expected_artist"] or ""
title = mismatch["expected_title"] or ""
convert_audio = get_setting_bool("default_convert_audio", False, user_id=user_id)
conn.execute(
"""INSERT INTO jobs
(id, title, artist, status, download_type, source, convert_audio, skip_mismatch_check, user_id)
VALUES (?, ?, ?, 'queued', 'single', 'youtube', ?, 1, ?)""",
(job_id, title, artist, int(convert_audio), user_id)
)
# Re-link the watched playlist track to this new job so the mismatch skip works
if mismatch["playlist_id"]:
track_hash = hash_track(artist, title)
conn.execute(
"UPDATE watched_playlist_tracks SET job_id = ?, downloaded_at = NULL "
"WHERE playlist_id = ? AND track_hash = ?",
(job_id, mismatch["playlist_id"], track_hash)
)
job = dict(conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone())
# Clean up the mismatch record since we're dealing with it
conn.execute("DELETE FROM watched_match_mismatches WHERE id = ?", (mismatch_id,))
conn.commit()
# Re-queue the download
convert_audio = bool(job.get("convert_audio", 1))
# Restore watched-playlist routing so the file lands in the right folder, not Singles.
_pl_name, _use_pl_dir = None, False
if mismatch.get("playlist_id"):
with db_conn() as conn:
conn.row_factory = sqlite3.Row
pl_row = conn.execute(
"SELECT name, use_playlists_dir FROM watched_playlists WHERE id = ?",
(mismatch["playlist_id"],)
).fetchone()
if pl_row and pl_row["use_playlists_dir"]:
_pl_name = pl_row["name"]
_use_pl_dir = True
if job.get("source") == "soulseek" and job.get("slskd_username") and job.get("slskd_filename"):
spawn_daemon_thread(
process_slskd_download,
job_id,
job["slskd_username"],
job["slskd_filename"],
job.get("artist", ""),
job.get("title", ""),
convert_audio,
user_id=user_id,
override_dir=job.get("override_dir"),
playlist_name=_pl_name,
use_playlists_dir=_use_pl_dir,
slskd_size=job.get("slskd_size"),
)
else:
prior_id = job.get("video_id") or ""
attempted = {prior_id} if prior_id else set()
new_id = prior_id
new_source_url = job.get("source_url")
artist_hint = job.get("artist") or ""
title_hint = job.get("title") or ""
if artist_hint or title_hint:
query = f"{artist_hint} - {title_hint}".strip(" -")
try:
for cand in search_all(query, limit=12,
slot_wait=SEARCH_SLOT_WAIT_AUTOMATED)[0]:
cand_id = (cand.get("video_id") or "").strip()
if cand_id and cand_id not in attempted:
new_id = cand_id
new_source_url = cand.get("source_url")
break
except Exception as e:
print(f"Force-accept alternate search failed for job {job_id}: {e}")
spawn_daemon_thread(
process_download,
job_id,
new_id,
convert_audio,
source_url=new_source_url,
playlist_name=_pl_name,
use_playlists_dir=_use_pl_dir,
user_id=user_id,
override_dir=job.get("override_dir"),
skip_dupe_check=bool(job.get("override_dir")),
attempted_ids=attempted,
)
return {"status": "queued", "job_id": job_id}
@app.get("/api/jobs/{job_id}/score-rationale")
def get_score_rationale(job_id: str, http_request: Request):
"""Return the scoring rationale for an automated download decision.
Shows why the scorer picked one candidate over its rivals, so users
can debug bad picks without becoming intimate with Docker logs.
"""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
# Verify the job belongs to this user (or they're admin)
job = conn.execute("SELECT user_id FROM jobs WHERE id = ?", (job_id,)).fetchone()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if not is_admin and job["user_id"] and job["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Not your job")
row = conn.execute(
"SELECT query, decision_json, created_at FROM search_decisions WHERE job_id = ? LIMIT 1",
(job_id,),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="No scoring data for this job")
try:
decision = json.loads(row["decision_json"])
except (ValueError, TypeError):
raise HTTPException(status_code=500, detail="Corrupt scoring data")
return {
"query": row["query"],
"decision": decision,
"created_at": row["created_at"],
}
# =============================================================================
# Search API
# =============================================================================
@app.get("/api/preview/{video_id}")
def get_preview_url(http_request: Request, video_id: str, source: str = "youtube",
url: str = None, artist: str = "", title: str = ""):
"""Get a streamable audio URL for preview playback."""
try:
# Direct MP3 sources: the source_url is already streamable - hand it
# straight to the browser, no yt-dlp round-trip needed.
if source in DIRECT_PREVIEW_SOURCES:
if not url:
raise HTTPException(status_code=400, detail=f"{source.capitalize()} preview requires url parameter")
return {"url": url, "video_id": video_id}
if source in MONOCHROME_PREVIEW_SOURCES:
if not url:
raise HTTPException(status_code=400, detail="Monochrome preview requires url parameter")
from monochrome import get_monochrome_preview_url
from urllib.parse import urlparse as _urlparse, parse_qs as _parse_qs
_parsed = _urlparse(url)
if _parsed.scheme != "monochrome" or not _parsed.netloc:
raise HTTPException(status_code=400, detail="Invalid Monochrome source URL")
if not (_parse_qs(_parsed.query).get("isrc") or [""])[0]:
raise HTTPException(status_code=400, detail="Monochrome source URL missing ISRC")
cdn_url = get_monochrome_preview_url(
url,
artist_hint=artist,
title_hint=title,
)
return {"url": cdn_url, "video_id": video_id}
if source == "youtube":
if not is_valid_youtube_id(video_id):
raise HTTPException(status_code=400, detail="Invalid YouTube video ID")
target_url = f"https://www.youtube.com/watch?v={video_id}"
base_args = _ytdlp_base_args()
elif source in URL_BASED_SOURCES:
if not url:
raise HTTPException(status_code=400, detail=f"{source.capitalize()} preview requires url parameter")
# Same trust boundary as /api/download: preview fetches from inside
# the container too, so it gets the same doorman.
try:
validate_source_url(source, url)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
target_url = url
base_args = [] # No cookies needed for URL-based non-YouTube sources
else:
raise HTTPException(status_code=400, detail=f"Preview not supported for source: {source}")
# SoundCloud returns HLS (.m3u8) for bestaudio which browsers can't
# play natively - prefer the direct HTTP MP3 stream for previews.
# Format IDs vary by track: older ones use http_mp3_1_0, newer ones
# use http_mp3_standard. Both resolve to a direct .mp3 on cf-media.sndcdn.com.
if source == "soundcloud":
fmt = "http_mp3_1_0/http_mp3_standard/bestaudio[protocol=http]/best"
else:
fmt = "bestaudio[ext=m4a]/bestaudio[ext=webm]/bestaudio/best"
cmd = [
"yt-dlp",
*base_args,
"-f", fmt,
"-g", # Get URL only, don't download
"--no-warnings",
target_url,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=TIMEOUT_YTDLP_PREVIEW)
if result.returncode != 0:
raise HTTPException(status_code=500, detail="Failed to get preview URL")
audio_url = result.stdout.strip()
if not audio_url:
raise HTTPException(status_code=404, detail="No audio stream found")
return {"url": audio_url, "video_id": video_id}
except HTTPException:
raise
except subprocess.TimeoutExpired:
raise HTTPException(status_code=504, detail="Preview request timed out")
except Exception as e:
print(f"preview error: {e}")
raise HTTPException(status_code=500, detail="Failed to get preview URL")
@app.get("/api/sources")
def list_sources():
"""Return available search sources for the frontend source selector."""
return {"sources": get_available_sources()}
@app.get("/api/sources/health")
def sources_health():
"""Per-source health snapshot; parked sources carry their cooldown reason.
Also carries the static build token. Every open tab polls this, so it is how
a tab left open across a redeploy finds out it is running yesterday's app.js."""
return {"sources": servicecheck.health_snapshot(), "build": _static_cache_bust()}
@app.post("/api/sources/health/recheck")
def recheck_sources_health(http_request: Request):
"""Ask every source for a fresh verdict without holding the HTTP request open."""
if not http_request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
spawn_daemon_thread(servicecheck.check_all_sources, True)
return {"status": "checking"}
@app.post("/api/search")
def search(request: SearchRequest, http_request: Request):
"""Search for music across configured sources."""
try:
source = request.source
album_suggestion = None
if source == "all":
raw_results, album_suggestion = search_all(
request.query, request.limit, include_soulseek=False,
slot_wait=SEARCH_SLOT_WAIT_INTERACTIVE,
)
elif source in SOURCE_REGISTRY:
raw_results = search_source(source, request.query, request.limit)
else:
raise HTTPException(status_code=400, detail=f"Unknown source: {source}")
# One projection for both the blocking and streaming search endpoints.
# These were duplicated, which is exactly how an artist fix could have
# landed in one response path and silently missed the other.
final_results = [
SearchResult(**project_search_result(item))
for item in raw_results[:request.limit]
]
search_token = None
try:
search_token = _log_search(request.query, len(final_results), source=source, user_id=http_request.state.user_id)
except Exception as log_error:
print(f"search log error: {log_error}")
# Surface any enabled-but-parked sources so the UI can explain the gaps.
parked = [
u for u in servicecheck.unavailable_sources()
if get_setting_bool(
f"source_{u['id']}_enabled",
SOURCE_REGISTRY.get(u["id"], {}).get("default_enabled", True),
)
]
resp = {
"results": final_results,
"slskd_enabled": slskd_enabled(),
"search_token": search_token,
"unavailable_sources": parked,
"duplicate_notice": _search_duplicate_notice(request.query, user_id=http_request.state.user_id),
}
if album_suggestion:
resp["album_suggestion"] = album_suggestion
return resp
except subprocess.TimeoutExpired:
raise HTTPException(status_code=504, detail="Search timed out")
except HTTPException:
raise
except SourceSearchBusy as e:
# We waited and it is still occupied. Nothing has broken, so a 500 would
# be a lie and an alarming one; 503 plus a Retry-After says what is
# actually true, which is "ask me again in a moment".
print(f"search busy: {e}")
raise HTTPException(
status_code=503,
detail="That source is busy with another search. Try again in a moment.",
headers={"Retry-After": "3"},
)
except Exception as e:
print(f"search error: {e}")
raise HTTPException(status_code=500, detail="Search failed")
@app.post("/api/search/stream")
def search_stream(request: SearchRequest, http_request: Request):
"""Live multi-source search: NDJSON stream, one event per line.
Same inputs as /api/search, but instead of blocking until the slowest source
answers, it streams progress as it happens: a 'start', a 'source' event per
source as it lands (or times out / is skipped), an optional 'album_suggestion',
and a terminal 'done' carrying the search_token and any parked sources. The
browser renders results progressively and ticks off the source status panel.
Soulseek rides along here like any other source (gated on its Search Sources
toggle, as the Settings help has always advised), retiring the old bespoke
slskd fetch-and-merge dance.
"""
source = request.source
if source != "all" and source not in SOURCE_REGISTRY:
raise HTTPException(status_code=400, detail=f"Unknown source: {source}")
# "all" gets the availability filter (park dead sources); an explicit single
# source is honoured even if parked, mirroring the old single-source search.
sources = None if source == "all" else [source]
enforce_availability = source == "all"
user_id = getattr(http_request.state, "user_id", None)
def _event_lines():
try:
for ev in _search_all_events(
request.query,
request.limit,
sources=sources,
include_soulseek=True,
enforce_availability=enforce_availability,
slot_wait=SEARCH_SLOT_WAIT_INTERACTIVE,
):
if ev["type"] == "source" and ev["status"] == "done":
ev = {**ev, "results": [project_search_result(r) for r in ev["results"]]}
elif ev["type"] == "done":
search_token = None
try:
# result_count is informational on the log row; the client
# already counted what it rendered, so a rough total is fine.
search_token = _log_search(request.query, 0, source=source, user_id=user_id)
except Exception as log_error:
print(f"search log error: {log_error}")
parked = [
u for u in servicecheck.unavailable_sources()
if get_setting_bool(
f"source_{u['id']}_enabled",
SOURCE_REGISTRY.get(u["id"], {}).get("default_enabled", True),
)
]
ev = {
**ev,
"search_token": search_token,
"unavailable_sources": parked,
"slskd_enabled": slskd_enabled(),
"duplicate_notice": _search_duplicate_notice(request.query, user_id=user_id),
}
yield json.dumps(ev) + "\n"
except Exception as e:
print(f"search stream error: {e}")
yield json.dumps({"type": "error", "detail": "Search failed"}) + "\n"
return StreamingResponse(_event_lines(), media_type="application/x-ndjson")
@app.post("/api/search/slskd")
def search_slskd_endpoint(request: SearchRequest):
"""Search Soulseek via slskd (slower, called separately)"""
if not slskd_enabled():
return {"results": [], "slskd_enabled": False}
try:
print(f"Searching slskd for: {request.query}")
slskd_results = search_slskd(request.query, timeout_secs=TIMEOUT_SLSKD_SEARCH)
print(f"slskd returned {len(slskd_results)} results")
final_results = []
for r in slskd_results[:request.limit]:
final_results.append(SearchResult(
video_id=r["id"],
title=r["title"],
artist=r["artist"],
channel=r["channel"],
duration=parse_duration(int(r["duration"])) if r["duration"].isdigit() else r["duration"],
thumbnail="",
is_playlist=False,
video_count=None,
source="soulseek",
quality=r["quality"],
relevance_score=r["relevance_score"],
slskd_username=r["slskd_username"],
slskd_filename=r["slskd_filename"],
slskd_size=r.get("slskd_size") or r.get("size"),
album=r.get("album") or None,
year=r.get("year"),
size_bytes=r.get("size") or r.get("slskd_size"),
bitrate=r.get("bitrate"),
match_confidence=r.get("match_confidence"),
queue_length=r.get("queue_length"),
))
return {"results": final_results, "slskd_enabled": True}
except Exception as e:
print(f"slskd search error: {type(e).__name__}: {e}")
return {"results": [], "slskd_enabled": True, "error": "Search failed - check server logs for details"}
@app.get("/api/search/artwork")
def search_artwork(artist: str, title: str = "", album: str = ""):
"""Return a cover art URL for display in search results.
Pass ``album`` for a per-album cover; Soulseek results carry one parsed out
of the shared file's path, so a page of results costs one lookup per album
rather than one per row. Falls back to the track lookup when there is no
album. Tries iTunes then Deezer, and the result is cached, so the second
result from the same album is free.
Returns the remote URL so the browser loads it directly; no image bytes are
proxied through MusicGrabber.
"""
artist = (artist or "").strip()
title = (title or "").strip()
album = (album or "").strip()
if not artist or not (album or title):
return {"url": None}
url = fetch_cover_art_url(artist, album or title, album=bool(album))
return {"url": url}
@app.post("/api/explore/similar")
def explore_similar(request: ExploreRequest, http_request: Request):
"""Artists resembling the one asked for, for the Explore bar.
The sourcing, ranking and identity resolution all live in `discovery`; this
is deliberately just the door. It used to be sixty lines of MusicBrainz and
ListenBrainz plumbing inline, which answered three of eight real seeds.
"""
artist = request.artist.strip()
if not artist:
raise HTTPException(status_code=400, detail="Artist name required")
found = discovery.similar_artists(
artist,
limit=max(1, min(request.limit, 25)),
user_id=http_request.state.user_id,
)
return {"artists": found, "artist": artist}
@app.get("/api/discover/rows")
def discover_rows(http_request: Request, limit: int = 5, per_row: int = 10):
"""Rows of "artists like X" for the Find New Artists page.
Seed-picking, sourcing and ranking all live in `discovery`; this just
clamps the query params and hands back what it is given.
"""
user_id = http_request.state.user_id
limit = max(1, min(limit, 10))
per_row = max(1, min(per_row, 20))
rows, signal = discovery.discovery_rows(user_id=user_id, limit=limit, per_row=per_row)
return {"rows": rows, "signal": signal}
@app.get("/api/discover/taster")
def discover_taster(http_request: Request, artist: str, deezer_id: int | None = None):
"""One preview track to introduce an artist by, for hovering over a discovery card."""
taster = discovery.artist_taster(
artist,
deezer_id=deezer_id,
user_id=http_request.state.user_id,
)
if taster is None:
raise HTTPException(status_code=404, detail="No preview available for that artist")
return taster
# =============================================================================
# Download API
# =============================================================================
@app.post("/api/download")
def download(body: DownloadRequest, http_request: Request):
"""Queue a download job"""
_enforce_peon_format(http_request, body)
job_id = str(uuid.uuid4())[:8]
user_id = http_request.state.user_id
# Extract artist/title if not provided
artist = body.artist
title = body.title
repair_context = None
if body.album_repair:
if body.download_type != "single" or body.playlist_name or body.use_playlists_dir:
raise HTTPException(status_code=400, detail="Album repair cannot be combined with playlist routing")
try:
repair_context = resolve_album_track_repair(
**body.album_repair.model_dump(), user_id=user_id,
)
except MusicBrainzUnavailable as exc:
raise HTTPException(status_code=503, detail=f"MusicBrainz unreachable: {exc}")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
artist = repair_context["track_artist"]
title = repair_context["track_title"]
# Create job record
with db_conn() as conn:
if repair_context:
conn.execute("BEGIN IMMEDIATE")
_ensure_album_repair_idle(conn, repair_context["override_dir"])
if album_repair_files(repair_context):
raise HTTPException(status_code=409, detail="A copy of this album track is present; use Search replacement again before downloading")
# Determine source type
source = body.source or "youtube"
if source == "soulseek" and not (body.slskd_username and body.slskd_filename):
source = "youtube" # Fallback if slskd fields missing
# A retired source (MP3Phoenix, most recently) can still turn up from a
# browser tab opened before the upgrade. Without this it fell through to
# the YouTube path and failed with a baffling "truncated YouTube ID".
if source not in SOURCE_REGISTRY:
raise HTTPException(status_code=400, detail=f"Unknown source: {source}")
# Validate based on source
if source == "youtube":
if not body.video_id or not is_valid_youtube_id(body.video_id):
raise HTTPException(status_code=400, detail="Invalid YouTube video ID")
elif source in URL_BASED_SOURCES:
if not body.source_url:
raise HTTPException(status_code=400, detail=f"{source.capitalize()} download requires source_url")
# The caller picks both the source and the URL, so the two agreeing is
# something we check, not something we assume.
try:
validate_source_url(source, body.source_url)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Build source URL for tracking
if source == "soulseek":
source_url = f"soulseek://{body.slskd_username}/{body.slskd_filename}" if body.slskd_username else None
elif source in URL_BASED_SOURCES:
source_url = body.source_url
elif body.download_type == "playlist":
source_url = f"https://www.youtube.com/playlist?list={body.video_id}" if body.video_id else None
else:
source_url = f"https://www.youtube.com/watch?v={body.video_id}" if body.video_id else None
album_release_mbid = (body.album_release_mbid or "").strip() or None
album_artist = (body.album_artist or "").strip() or None
album_name = (body.album_name or "").strip() or None
album_track_title = (body.album_track_title or "").strip() or None
album_track_number = int(body.album_track_number) if body.album_track_number else None
album_track_total = int(body.album_track_total) if body.album_track_total else None
if album_track_number is not None and album_track_number <= 0:
album_track_number = None
if album_track_total is not None and album_track_total <= 0:
album_track_total = None
override_dir = None
album_fields_present = any([album_release_mbid, album_artist, album_name, album_track_title, album_track_number, album_track_total])
if album_fields_present:
if body.download_type == "playlist":
raise HTTPException(status_code=400, detail="Album routing cannot be used with playlist downloads")
# Full album routing requires MBID + name + track title.
# Exception: no_mbid folder-only routing just needs album_name + album_artist.
if album_release_mbid and (not album_name or not album_track_title):
raise HTTPException(status_code=400, detail="album_name and album_track_title are required when album_release_mbid is provided")
if not album_release_mbid and not album_name:
raise HTTPException(status_code=400, detail="album_name is required for album folder routing")
route_artist = album_artist or (artist or "").strip()
if not route_artist:
raise HTTPException(status_code=400, detail="album_artist (or artist) is required for album routing")
override_dir = str(get_albums_dir(user_id=user_id) / sanitize_filename(route_artist) / sanitize_filename(album_name))
# album_name is the folder picked in "Add to album...", which may be
# "Album (2004)". The year belongs on the folder, not in the ALBUM tag:
# take the name from the sidecar, or drop a Lidarr-style year without one.
try:
info = json.loads((Path(override_dir) / ".albuminfo").read_text(encoding="utf-8"))
except (OSError, ValueError):
info = {}
album_name = (info.get("album") or "").strip() or re.sub(r"\s\(\d{4}\)$", "", album_name)
if repair_context:
override_dir = repair_context["override_dir"]
album_release_mbid = repair_context["release_mbid"]
album_artist = repair_context["album_artist"]
album_name = repair_context["album_name"]
album_track_title = repair_context["track_title"]
album_track_number = repair_context["track_number"]
album_track_total = repair_context["track_total"]
valid_search_token = _validated_search_token(body.search_token, user_id=user_id)
# Only a server-issued search token turns this client-supplied value into
# manual-result evidence. API callers without a preceding search cannot
# accidentally make an automated download use the manual bypass/gate.
selected_duration_secs = (
float(body.selected_duration_secs)
if valid_search_token and body.selected_duration_secs
else None
)
if body.download_type == "playlist":
conn.execute(
"""INSERT INTO jobs
(id, video_id, title, status, download_type, playlist_name, source, convert_audio, source_url, search_token, selected_duration_secs, user_id,
override_dir, album_release_mbid, album_name, album_track_title, album_track_number, album_track_total)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
job_id, body.video_id, title, "queued", "playlist", title, "youtube", int(body.convert_audio), source_url, valid_search_token, None, user_id,
None, None, None, None, None, None,
)
)
else:
conn.execute(
"""INSERT INTO jobs
(id, video_id, title, artist, status, download_type, source, slskd_username, slskd_filename, slskd_size, convert_audio, source_url, search_token, selected_duration_secs, user_id,
override_dir, album_release_mbid, album_name, album_track_title, album_track_number, album_track_total)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
job_id, body.video_id, title, artist or "", "queued", "single", source,
body.slskd_username, body.slskd_filename, body.slskd_size, int(body.convert_audio), source_url, valid_search_token, selected_duration_secs, user_id,
override_dir, album_release_mbid, album_name, album_track_title, album_track_number, album_track_total,
)
)
if body.playlist_name:
# The manual Search route routes the file correctly via
# playlist_name/use_playlists_dir, but until now never linked
# the job to its watched_playlist_tracks row, leaving that to
# _mark_watched_track_downloaded's after-the-fact artist/title
# healing once the download finished. Link it here instead, so
# Retry, Replace and the M3U rebuild can all find this job by
# job_id from the moment it's queued rather than waiting for
# the download to finish and heal itself. A playlist_name with
# no matching watched row (a one-off manual playlist) simply
# matches nothing here, which is fine.
track_hash = hash_track(artist or "", title)
conn.execute(
"""UPDATE watched_playlist_tracks
SET job_id = ?, downloaded_at = NULL
WHERE track_hash = ?
AND playlist_id IN (
SELECT id FROM watched_playlists WHERE user_id IS ? AND name = ?
)""",
(job_id, track_hash, user_id, body.playlist_name),
)
if repair_context:
conn.execute("UPDATE jobs SET album_context = ? WHERE id = ?", (json.dumps(repair_context), job_id))
conn.commit()
acquisition_target_id = None
acquisition_cycle = None
selected_candidate = None
if body.download_type != "playlist":
acquisition_target_id = ensure_acquisition_target(
owner_type="manual",
owner_key=job_id,
mode="manual",
artist=artist or "",
title=title,
user_id=user_id,
allowed_sources={source},
priority_source=source,
convert_audio=body.convert_audio,
destination={
"playlist_name": body.playlist_name,
"use_playlists_dir": bool(body.use_playlists_dir),
"override_dir": override_dir,
"album_release_mbid": album_release_mbid,
},
)
acquisition_cycle = begin_acquisition_cycle(acquisition_target_id)
attach_job_to_acquisition(job_id, acquisition_target_id, acquisition_cycle)
selected_candidate = {
"video_id": body.video_id,
"title": title,
"artist": artist or "",
"channel": artist or "",
"source": source,
"source_url": source_url,
"slskd_username": body.slskd_username,
"slskd_filename": body.slskd_filename,
"slskd_size": body.slskd_size,
}
# If this is an album-routed single track, register a lock so any retry
# can skip dupe check without relying on the transient skip_dupe_check flag.
if override_dir and album_track_title:
_route_artist = album_artist or (artist or "").strip()
upsert_album_track_lock(album_release_mbid, album_name or "", _route_artist, album_track_title, job_id)
# Queue the download based on source
if body.download_type == "playlist":
spawn_daemon_thread(process_playlist_download, job_id, body.video_id, title, body.convert_audio, True,
user_id=user_id)
else:
spawn_daemon_thread(
process_acquisition_cycle,
job_id,
selected_candidate,
artist or "",
title,
body.convert_audio,
target_id=acquisition_target_id,
cycle=acquisition_cycle,
automatic=False,
allowed_sources={source},
priority_source=source,
playlist_name=body.playlist_name,
use_playlists_dir=body.use_playlists_dir,
user_id=user_id,
override_dir=override_dir,
skip_dupe_check=bool(override_dir),
)
return {"job_id": job_id, "status": "queued"}
# =============================================================================
# Job Management API
# =============================================================================
def _ensure_utc_suffix(timestamp: str | None) -> str | None:
"""Ensure timestamp has UTC indicator for proper JS parsing.
SQLite's CURRENT_TIMESTAMP and datetime('now') return UTC but without
timezone suffix. JavaScript's Date() treats such strings as local time.
Appending 'Z' tells JS to interpret as UTC.
"""
if not timestamp:
return timestamp
# Already has timezone info
if timestamp.endswith('Z') or '+' in timestamp[-6:]:
return timestamp
# SQLite format uses space, ISO uses T
return timestamp.replace(' ', 'T') + 'Z'
def _expose_conversion_intent(row: dict) -> dict:
"""Normalise the conversion flag and keep the pre-v4 field name alongside it.
The column is honest now, so this exists only for clients written against the
old name. Works for jobs, watched playlists and watched artists alike; they all
carry the same flag under the same column.
"""
convert_audio = bool(row.get("convert_audio", 0))
row["convert_audio"] = convert_audio
row["convert_to_flac"] = convert_audio
return row
def _attach_acquisition_summaries(conn, jobs: list[dict]) -> None:
"""Attach durable retry history without turning the Queue query into N+1 SQL.
A retry never resurrects a failed Queue row, it opens a new one for the next
cycle and leaves the failed row standing as a record of what happened (see
`_create_rescue_job`). Left unscoped, this function used to hand every job
sharing a target the *target's* whole saga, so an old failed card could show
a later cycle's "completed" line under its own permanently-red status, an
outright contradiction. Each job now only sees its own cycle and earlier;
if a later cycle for the same target went on to succeed, it gets a pointer
to that job instead of a share of its history.
"""
target_ids = sorted({
job.get("acquisition_target_id") for job in jobs
if job.get("acquisition_target_id")
})
if not target_ids:
return
placeholders = ",".join("?" * len(target_ids))
target_rows = conn.execute(
f"""SELECT id, mode, cycle_count, attempt_count, status,
last_attempt_at, last_error
FROM acquisition_targets WHERE id IN ({placeholders})""",
target_ids,
).fetchall()
targets = {row["id"]: dict(row) for row in target_rows}
attempt_rows = conn.execute(
f"""SELECT target_id, cycle_number, sequence, source, candidate_id,
candidate_title, candidate_artist, candidate_quality,
status, error, started_at, completed_at
FROM (
SELECT target_id, cycle_number, sequence, source, candidate_id,
candidate_title, candidate_artist, candidate_quality,
status, error, started_at, completed_at,
ROW_NUMBER() OVER (
PARTITION BY target_id
ORDER BY cycle_number DESC, sequence DESC
) AS recent_rank
FROM acquisition_attempts
WHERE target_id IN ({placeholders})
)
WHERE recent_rank <= 20
ORDER BY target_id, cycle_number, sequence""",
target_ids,
).fetchall()
histories: dict[str, list[dict]] = {}
for row in attempt_rows:
history = histories.setdefault(row["target_id"], [])
history.append(dict(row))
# Later cycle, same target, actually landed: work out which job gets to
# claim the win, so an earlier failed job can point to it by ID.
completed_target_ids = [tid for tid, t in targets.items() if t["status"] == "completed"]
winning_job_of_target: dict[str, str] = {}
if completed_target_ids:
placeholders2 = ",".join("?" * len(completed_target_ids))
winner_rows = conn.execute(
f"""SELECT acquisition_target_id, id FROM jobs
WHERE acquisition_target_id IN ({placeholders2})
AND status IN ('completed', 'completed_with_errors')
ORDER BY acquisition_cycle DESC""",
completed_target_ids,
).fetchall()
for row in winner_rows:
winning_job_of_target.setdefault(row["acquisition_target_id"], row["id"])
for job in jobs:
target = targets.get(job.get("acquisition_target_id"))
if not target:
continue
own_cycle = job.get("acquisition_cycle")
full_history = histories.get(target["id"], [])
history = (
[a for a in full_history if int(a.get("cycle_number") or 0) <= int(own_cycle)]
if own_cycle is not None else full_history
)
job["acquisition_mode"] = target["mode"]
job["acquisition_cycle_count"] = int(own_cycle) if own_cycle is not None else int(target["cycle_count"] or 0)
job["acquisition_attempt_count"] = len(history)
job["acquisition_status"] = target["status"]
scoped_last_attempt = max(
(a.get("completed_at") or a.get("started_at") or "" for a in history), default=""
)
job["acquisition_last_attempt_at"] = _ensure_utc_suffix(scoped_last_attempt or target["last_attempt_at"])
job["acquisition_last_error"] = target["last_error"]
job["acquisition_attempts"] = [
{
**attempt,
"started_at": _ensure_utc_suffix(attempt.get("started_at")),
"completed_at": _ensure_utc_suffix(attempt.get("completed_at")),
}
for attempt in history
]
winning_job_id = winning_job_of_target.get(target["id"])
if (
winning_job_id and winning_job_id != job["id"]
and job.get("status") == "failed"
and own_cycle is not None and int(own_cycle) < int(target["cycle_count"] or 0)
):
job["acquisition_superseded_by"] = winning_job_id
@app.get("/api/jobs")
def get_jobs(limit: int = 20, import_id: str | None = None, q: str | None = None,
status: str | None = None, http_request: Request = None):
"""Get recent jobs.
import_id scopes the result to one bulk import's tracks instead of the
general recent list, ordered to match the original input rather than
creation time, and without the usual limit truncating a big playlist.
Lets the Bulk Import tab draw its progress with the exact same job cards
(and retry buttons) as the Queue tab, rather than a second copy of them.
q filters on artist/title/album/playlist name across the whole jobs table,
not just the newest `limit` rows the Queue tab normally shows. Somebody who
imported a 900-track playlist and now wants to fix the one track that came
back as a Minecraft parody should not have to page through 89 other ones.
status narrows to one exact job status, also across the whole table, which
is how the Stats tab's Failed count lands on the failures themselves rather
than on whichever few happen to be among the newest 250.
"""
from pathlib import Path as _Path
user_id = http_request.state.user_id if http_request else None
is_admin = http_request.state.is_admin if http_request else True
# LIKE has its own wildcards; a user searching for "50_cent" means an
# underscore, not "any character". Escape them rather than surprise them.
search_frag, search_params = "", ()
term = (q or "").strip()
if term:
escaped = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%{escaped}%"
search_frag = (
" AND (COALESCE(j.artist, '') LIKE ? ESCAPE '\\'"
" OR COALESCE(j.title, '') LIKE ? ESCAPE '\\'"
" OR COALESCE(j.album_name, '') LIKE ? ESCAPE '\\'"
" OR COALESCE(j.playlist_name, '') LIKE ? ESCAPE '\\')"
)
search_params = (pattern, pattern, pattern, pattern)
if status:
search_frag += " AND j.status = ?"
search_params += (status,)
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
if import_id:
rows = conn.execute(
f"""SELECT j.* FROM jobs j
JOIN bulk_import_tracks t ON t.job_id = j.id
WHERE t.import_id = ? AND {_scope_frag}{search_frag}
ORDER BY t.line_num ASC""",
(import_id, *_scope_params, *search_params)
).fetchall()
else:
rows = conn.execute(
f"SELECT j.* FROM jobs j WHERE {_scope_frag}{search_frag} ORDER BY created_at DESC LIMIT ?",
(*_scope_params, *search_params, limit)
).fetchall()
# Pre-fetch resolved_paths for all jobs in one query.
# check_duplicate only walks Singles; playlist-folder tracks would be
# falsely marked deleted without this.
if rows:
job_ids = [r["id"] for r in rows]
placeholders = ",".join("?" * len(job_ids))
rp_rows = conn.execute(
f"SELECT job_id, resolved_path FROM watched_playlist_tracks WHERE job_id IN ({placeholders}) AND resolved_path IS NOT NULL",
job_ids,
).fetchall()
resolved_paths: dict = {r["job_id"]: r["resolved_path"] for r in rp_rows}
else:
resolved_paths = {}
jobs = []
stale_ids = [] # Jobs that claim file exists but it doesn't
for row in rows:
job = _expose_conversion_intent(dict(row))
job['created_at'] = _ensure_utc_suffix(job.get('created_at'))
job['completed_at'] = _ensure_utc_suffix(job.get('completed_at'))
job['progress_at'] = _ensure_utc_suffix(job.get('progress_at'))
try:
job['source_history'] = json.loads(job.get('source_history') or '[]')
except (TypeError, ValueError):
job['source_history'] = []
if not job['source_history'] and job.get('source'):
job['source_history'] = [job['source']]
# Keep the queue API cheap. Full library reconciliation can walk a
# large/remote music tree and is handled by the background monitor;
# here we only verify exact paths already stored on watched rows.
if (job.get('status') in ('completed', 'completed_with_errors')
and not job.get('file_deleted')
and job.get('artist') and job.get('title')):
known_path = job.get('final_path') or resolved_paths.get(job['id'])
if known_path and _Path(known_path).is_absolute() and not _Path(known_path).exists():
job['file_deleted'] = 1
stale_ids.append(job['id'])
jobs.append(job)
# Batch-update any jobs whose files have gone walkabout
if stale_ids:
conn.executemany(
"UPDATE jobs SET file_deleted = 1 WHERE id = ?",
[(jid,) for jid in stale_ids]
)
conn.commit()
_attach_acquisition_summaries(conn, jobs)
# Live counts for the whole queue, never narrowed by q, status, or the newest-N
# window. The tab's "3 on the workbench" banner and the queue-size guard
# both read these, so searching for one track does not convince the UI
# that the other 800 have quietly emigrated.
totals = None
if not import_id:
totals = {
row["status"]: row["n"] for row in conn.execute(
f"SELECT status, COUNT(*) AS n FROM jobs WHERE {_scope_frag} GROUP BY status",
_scope_params
).fetchall()
}
return {"jobs": jobs, "queue_totals": totals}
@app.get("/api/jobs/downloadable")
def get_downloadable_jobs(page: int = 1, per_page: int = 50, http_request: Request = None):
"""Return completed jobs with files available to save to device, newest first."""
offset = (page - 1) * per_page
user_id = http_request.state.user_id if http_request else None
is_admin = http_request.state.is_admin if http_request else True
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
total = conn.execute(
f"SELECT COUNT(*) FROM jobs WHERE status = 'completed' AND (file_deleted IS NULL OR file_deleted = 0) AND {_scope_frag}",
_scope_params
).fetchone()[0]
rows = conn.execute(
f"""SELECT id, title, artist, status, created_at, completed_at
FROM jobs
WHERE status = 'completed' AND (file_deleted IS NULL OR file_deleted = 0)
AND {_scope_frag}
ORDER BY completed_at DESC
LIMIT ? OFFSET ?""",
(*_scope_params, per_page, offset)
).fetchall()
return {
"total": total,
"page": page,
"per_page": per_page,
"pages": max(1, (total + per_page - 1) // per_page),
"jobs": [dict(r) for r in rows],
}
@app.get("/api/jobs/{job_id}")
def get_job(job_id: str, http_request: Request):
"""Get a specific job"""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
cursor = conn.execute(
f"SELECT * FROM jobs WHERE id = ? AND {_scope_frag}",
(job_id, *_scope_params)
)
row = cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Job not found")
job = _expose_conversion_intent(dict(row))
job['created_at'] = _ensure_utc_suffix(job.get('created_at'))
job['completed_at'] = _ensure_utc_suffix(job.get('completed_at'))
job['progress_at'] = _ensure_utc_suffix(job.get('progress_at'))
try:
job['source_history'] = json.loads(job.get('source_history') or '[]')
except (TypeError, ValueError):
job['source_history'] = []
if not job['source_history'] and job.get('source'):
job['source_history'] = [job['source']]
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_attach_acquisition_summaries(conn, [job])
return job
@app.get("/api/jobs/{job_id}/download")
def download_job_file(job_id: str, http_request: Request):
"""Serve the downloaded audio file for a job directly to the browser."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
cursor = conn.execute(
f"SELECT * FROM jobs WHERE id = ? AND {_scope_frag}",
(job_id, *_scope_params)
)
row = cursor.fetchone()
# Also fish out the resolved_path from watched_playlist_tracks if it exists.
# This is more reliable than reconstructing the path, especially for playlist folders.
rp_cursor = conn.execute(
"SELECT resolved_path FROM watched_playlist_tracks WHERE job_id = ? AND resolved_path IS NOT NULL LIMIT 1",
(job_id,)
)
rp_row = rp_cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Job not found")
job = dict(row)
if job.get("file_deleted"):
raise HTTPException(status_code=404, detail="File has been deleted")
if job.get("status") not in ("completed", "completed_with_errors"):
raise HTTPException(status_code=404, detail="Job not yet complete")
artist = job.get("artist", "")
title = job.get("title", "")
if not title:
raise HTTPException(status_code=404, detail="Job has no title")
from utils import check_duplicate, sanitize_filename
# Prefer the job's authoritative final path, then the older watched-row path.
file_path = authoritative_audio_file(job.get("final_path"), user_id)
if file_path is None and rp_row:
file_path = authoritative_audio_file(rp_row["resolved_path"], user_id)
# Fall back to walking the Singles layout (covers non-watched single downloads).
if file_path is None:
file_path = check_duplicate(artist, title, user_id=user_id)
if not file_path or not file_path.exists():
raise HTTPException(status_code=404, detail="File not found on disk")
# Use "Artist - Title.ext" as the download filename regardless of where it lives.
download_name = f"{sanitize_filename(artist)} - {sanitize_filename(title)}{file_path.suffix}" if artist else f"{sanitize_filename(title)}{file_path.suffix}"
return FileResponse(
path=str(file_path),
media_type="application/octet-stream",
filename=download_name,
)
_AUDIO_MIME_TYPES = {
".flac": "audio/flac", ".opus": "audio/ogg", ".ogg": "audio/ogg",
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".webm": "audio/webm",
}
@app.get("/api/jobs/{job_id}/stream")
def stream_job_file(job_id: str, http_request: Request):
"""Stream the audio file for in-browser playback."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
row = conn.execute(
f"SELECT * FROM jobs WHERE id = ? AND {_scope_frag}",
(job_id, *_scope_params)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Job not found")
job = dict(row)
artist = job.get("artist", "")
title = job.get("title", "")
if not title:
raise HTTPException(status_code=404, detail="Job has no title")
from utils import check_duplicate
file_path = authoritative_audio_file(job.get("final_path"), user_id)
if file_path is None:
file_path = check_duplicate(artist, title, user_id=user_id)
if not file_path or not file_path.exists():
raise HTTPException(status_code=404, detail="File not found on disk")
mime = _AUDIO_MIME_TYPES.get(file_path.suffix.lower(), "audio/mpeg")
return FileResponse(path=str(file_path), media_type=mime)
@app.post("/api/jobs/{job_id}/retry")
def retry_job(job_id: str, http_request: Request):
"""Retry a failed job"""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
cursor = conn.execute(
f"SELECT * FROM jobs WHERE id = ? AND {_scope_frag}",
(job_id, *_scope_params)
)
row = cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Job not found")
job = dict(row)
# Track Upgrades jobs are a swap-in-place, not a standalone acquisition: their
# staging directory is long gone by the time this row is visible, so retrying
# would silently download into a folder nobody will ever look in again.
if job["download_type"] == "upgrade":
raise HTTPException(
status_code=400,
detail="Track upgrade jobs can't be re-downloaded from here; search again from Watched Upgrades",
)
# Allow retrying failed jobs or re-downloading completed jobs
if job["status"] not in ("failed", "completed", "completed_with_errors"):
raise HTTPException(status_code=400, detail="Only failed or completed jobs can be retried")
# Reset job status
conn.execute(
"UPDATE jobs SET status = ?, error = NULL, completed_at = NULL, file_deleted = 0, final_path = NULL, "
"progress_at = datetime('now') WHERE id = ?",
("queued", job_id)
)
conn.commit()
# Re-queue the job based on source type
convert_audio = bool(job.get("convert_audio", 1))
source_allowlist = get_job_source_allowlist(job_id)
# Restore watched-playlist routing so retried jobs land in Playlists, not Singles.
# The routing flags are not stored on the job row itself, so we look them up via
# watched_playlist_tracks (queue-track-candidate path) or bulk_import_tracks (refresh path).
_pl_name, _use_pl_dir = None, False
if job["download_type"] != "playlist":
with db_conn() as conn:
conn.row_factory = sqlite3.Row
pl_row = conn.execute("""
SELECT wp.name, wp.use_playlists_dir
FROM watched_playlist_tracks wpt
JOIN watched_playlists wp ON wp.id = wpt.playlist_id
WHERE wpt.job_id = ?
""", (job_id,)).fetchone()
if not pl_row:
pl_row = conn.execute("""
SELECT wp.name, wp.use_playlists_dir
FROM bulk_import_tracks bit
JOIN bulk_imports bi ON bi.id = bit.import_id
JOIN watched_playlists wp ON wp.id = bi.watch_playlist_id
WHERE bit.job_id = ?
""", (job_id,)).fetchone()
if pl_row and pl_row["use_playlists_dir"]:
_pl_name = pl_row["name"]
_use_pl_dir = True
if job["download_type"] == "playlist":
spawn_daemon_thread(process_playlist_download, job_id, job["video_id"], job["playlist_name"], convert_audio, True,
user_id=user_id)
elif job.get("source") == "soulseek" and job.get("slskd_username") and job.get("slskd_filename"):
spawn_daemon_thread(
process_slskd_download,
job_id,
job["slskd_username"],
job["slskd_filename"],
job.get("artist", ""),
job.get("title", ""),
convert_audio,
user_id=user_id,
override_dir=job.get("override_dir"),
playlist_name=_pl_name,
use_playlists_dir=_use_pl_dir,
slskd_size=job.get("slskd_size"),
)
else:
# For both YouTube and URL-based sources (SoundCloud, zvu4no, etc):
# search the job's allowed sources and pick the best untried candidate.
# Re-trying the same source_url or video_id that already failed is pointless.
prior_id = job.get("video_id") or ""
attempted = {prior_id} if prior_id else set()
new_id = prior_id
new_source_url = None
artist_hint = job.get("artist") or ""
title_hint = job.get("title") or ""
if artist_hint or title_hint:
query = f"{artist_hint} - {title_hint}".strip(" -")
try:
retry_sources = sorted(source_allowlist) if source_allowlist is not None else None
for cand in search_all(query, limit=12, sources=retry_sources,
slot_wait=SEARCH_SLOT_WAIT_AUTOMATED)[0]:
cand_id = (cand.get("video_id") or "").strip()
if cand_id and cand_id not in attempted:
new_id = cand_id
new_source_url = cand.get("source_url")
break
except Exception as e:
print(f"Re-download alternate search failed for job {job_id}: {e}")
spawn_daemon_thread(
process_download,
job_id,
new_id,
convert_audio,
source_url=new_source_url,
playlist_name=_pl_name,
use_playlists_dir=_use_pl_dir,
user_id=user_id,
override_dir=job.get("override_dir"),
skip_dupe_check=bool(job.get("override_dir")),
attempted_ids=attempted,
fallback_allowed_sources=source_allowlist,
)
return {"job_id": job_id, "status": "queued"}
def _rescue_context(job_id: str, user_id: str | None, is_admin: bool) -> tuple[dict, dict]:
"""Load an owned failed job and its durable automatic-acquisition target."""
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
_scope_frag = _scope_frag.replace("user_id", "j.user_id")
row = conn.execute(
f"""SELECT j.*, at.user_id AS target_user_id, at.owner_type,
at.owner_key, at.mode AS target_mode, at.artist AS target_artist,
at.title AS target_title, at.isrc AS target_isrc,
at.allowed_sources, at.priority_source,
at.convert_audio AS target_convert_audio,
at.destination_json, at.status AS target_status
FROM jobs j
JOIN acquisition_targets at ON at.id = j.acquisition_target_id
WHERE j.id = ? AND {_scope_frag}""",
(job_id, *_scope_params),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Automatic acquisition job not found")
data = dict(row)
if data.get("target_mode") != "automatic":
raise HTTPException(
status_code=400,
detail="This was a manually pinned source; use Re-download to honour that source",
)
if data.get("status") != "failed":
raise HTTPException(status_code=400, detail="Only failed automatic jobs can be rescued")
try:
destination = json.loads(data.get("destination_json") or "{}")
except (TypeError, ValueError):
destination = {}
return data, destination
def _candidate_can_download(candidate: dict) -> bool:
source = (candidate.get("source") or "youtube").strip().lower()
if source == "youtube":
return bool(candidate.get("video_id") and is_valid_youtube_id(candidate["video_id"]))
if source == "soulseek":
return bool(candidate.get("slskd_username") and candidate.get("slskd_filename"))
if source in URL_BASED_SOURCES:
# Rescue candidates are replayed automatically later, well away from any
# human, so a bogus URL that slipped into storage must not get a second
# chance at being fetched.
return is_valid_source_url(source, candidate.get("source_url"))
return False
def _rescue_candidates_for_target(target_id: str, allowed_sources: set[str] | None) -> list[dict]:
with db_conn() as conn:
conn.row_factory = sqlite3.Row
candidates = stored_rescue_candidates(conn, target_id)
attempts = conn.execute(
"""SELECT source, candidate_id
FROM acquisition_attempts WHERE target_id = ?""",
(target_id,),
).fetchall()
attempted = {
((row["source"] or "youtube").lower(), str(row["candidate_id"] or ""))
for row in attempts
}
results = []
for candidate in candidates:
source = (candidate.get("source") or "youtube").strip().lower()
if allowed_sources is not None and source not in allowed_sources:
continue
candidate["attempted"] = (source, str(candidate.get("video_id") or "")) in attempted
candidate["selectable"] = _candidate_can_download(candidate)
results.append(candidate)
return results
def _rescue_routing(job: dict, destination: dict) -> tuple[str | None, bool, str | None]:
use_playlists_dir = bool(destination.get("use_playlists_dir"))
custom_subdir = (destination.get("custom_subdir") or "").strip() or None
playlist_name = (destination.get("playlist_name") or "").strip() or None
watch_playlist_id = destination.get("watch_playlist_id")
if watch_playlist_id:
with db_conn() as conn:
row = conn.execute(
"SELECT name, use_playlists_dir, custom_subdir FROM watched_playlists WHERE id = ?",
(watch_playlist_id,),
).fetchone()
if row:
playlist_name = row[0]
use_playlists_dir = bool(row[1])
custom_subdir = (row[2] or "").strip() or custom_subdir
if not (use_playlists_dir or custom_subdir):
playlist_name = None
return playlist_name, use_playlists_dir, custom_subdir
def _search_rescue_candidates(job: dict, slot_wait: float) -> list[dict]:
"""Repeat automatic search without dropping an album/playlist's ISRC identity."""
artist = job.get("target_artist") or job.get("artist") or ""
title = job.get("target_title") or job.get("title") or ""
allowed = parse_allowed_sources(job.get("allowed_sources"))
raw: list[dict] = []
isrc = (job.get("target_isrc") or "").strip()
if isrc and (allowed is None or "monochrome" in allowed):
try:
from monochrome import resolve_by_isrc
exact = resolve_by_isrc(isrc, artist, title)
if exact:
raw.append(exact)
except Exception as exc:
print(f"Rescue ISRC lookup failed for {artist} - {title}: {exc}")
searched, _ = search_all(
f"{artist} - {title}".strip(" -"),
limit=12,
sources=sorted(allowed) if allowed is not None else None,
include_soulseek=True,
slot_wait=slot_wait,
return_all_source_results=True,
)
seen = {
((item.get("source") or "youtube").lower(), str(item.get("video_id") or ""))
for item in raw
}
raw.extend(
item for item in searched
if ((item.get("source") or "youtube").lower(), str(item.get("video_id") or ""))
not in seen
)
ranked, _rejected = rank_automatic_candidates(
raw, artist, title, priority_source=job.get("priority_source")
)
return ranked
def _create_rescue_job(job: dict, destination: dict, candidate: dict | None = None) -> tuple[str, int]:
"""Append a new Queue row without erasing the failed attempt it rescues."""
target_id = job["acquisition_target_id"]
new_job_id = str(uuid.uuid4())[:8]
candidate = candidate or {}
source = (candidate.get("source") or job.get("source") or "youtube").strip().lower()
video_id = candidate.get("video_id") or ""
source_url = candidate.get("source_url")
if source == "youtube" and video_id and not source_url:
source_url = f"https://www.youtube.com/watch?v={video_id}"
if source == "soulseek" and not source_url and candidate.get("slskd_username"):
source_url = f"soulseek://{candidate['slskd_username']}/{candidate.get('slskd_filename') or ''}"
with db_conn() as conn:
# BEGIN IMMEDIATE takes the write lock before we look, so the "is one
# already running?" check and the row that answers it cannot be separated.
# Two clicks on Retry used to both find nothing running and both queue a
# rescue, doubling up on a target that is only allowed one at a time.
conn.execute("BEGIN IMMEDIATE")
active = conn.execute(
"""SELECT 1 FROM jobs
WHERE acquisition_target_id = ? AND status IN ('queued', 'downloading')
LIMIT 1""",
(target_id,),
).fetchone()
if active:
conn.execute("ROLLBACK")
raise HTTPException(status_code=409, detail="A rescue attempt is already running")
# Claiming the cycle inside the same transaction means a rescue that
# loses the race does not quietly burn a cycle on its way out.
cycle = begin_acquisition_cycle(target_id, conn=conn)
conn.execute(
"""INSERT INTO jobs
(id, video_id, title, artist, status, download_type, playlist_name,
source, slskd_username, slskd_filename, slskd_size, convert_audio,
source_url, user_id, override_dir, album_release_mbid, album_name,
album_track_title, album_track_number, album_track_total,
skip_mismatch_check, acquisition_target_id, acquisition_cycle,
progress_stage)
VALUES (?, ?, ?, ?, 'queued', 'single', ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, 'Finding next option')""",
(
new_job_id, video_id, job.get("target_title") or job.get("title") or "",
job.get("target_artist") or job.get("artist") or "",
job.get("playlist_name"), source,
candidate.get("slskd_username"), candidate.get("slskd_filename"),
candidate.get("slskd_size") or candidate.get("size"),
int(bool(job.get("target_convert_audio"))), source_url,
job.get("target_user_id") or job.get("user_id"),
destination.get("override_dir") or job.get("override_dir"),
destination.get("album_release_mbid") or job.get("album_release_mbid"),
job.get("album_name"), job.get("album_track_title"),
job.get("album_track_number"), job.get("album_track_total"),
int(bool(job.get("skip_mismatch_check"))), target_id, cycle,
),
)
if job.get("album_context"):
conn.execute("UPDATE jobs SET album_context = ? WHERE id = ?", (job["album_context"], new_job_id))
conn.execute(
"""UPDATE bulk_import_tracks
SET job_id = ?, status = 'queued', error = NULL, acquisition_cycle = ?
WHERE acquisition_target_id = ?""",
(new_job_id, cycle, target_id),
)
watch_playlist_id = destination.get("watch_playlist_id")
if watch_playlist_id:
conn.execute(
"""UPDATE watched_playlist_tracks
SET job_id = ?, downloaded_at = NULL
WHERE playlist_id = ? AND track_hash = ?""",
(
new_job_id, watch_playlist_id,
hash_track(job.get("target_artist") or "", job.get("target_title") or ""),
),
)
watch_artist_id = destination.get("watch_artist_id")
if watch_artist_id:
conn.execute(
"""UPDATE watched_artist_tracks
SET job_id = ?, downloaded_at = NULL
WHERE artist_id = ? AND track_hash = ?""",
(
new_job_id, watch_artist_id,
hash_track(job.get("target_artist") or "", job.get("target_title") or ""),
),
)
conn.commit()
return new_job_id, cycle
def _run_rescue_cycle(
job: dict,
destination: dict,
new_job_id: str,
cycle: int,
candidates: list[dict],
automatic: bool,
) -> None:
target_id = job["acquisition_target_id"]
artist = job.get("target_artist") or job.get("artist") or ""
title = job.get("target_title") or job.get("title") or ""
allowed_sources = parse_allowed_sources(job.get("allowed_sources"))
priority_source = job.get("priority_source")
if automatic:
untried = [candidate for candidate in candidates if not candidate.get("attempted")]
candidate_pool = [candidate for candidate in untried if _candidate_can_download(candidate)]
if not candidate_pool:
try:
candidate_pool = _search_rescue_candidates(
job, SEARCH_SLOT_WAIT_AUTOMATED
)
except Exception as exc:
candidate_pool = []
failure = f"Rescue search failed: {exc}"
else:
candidate_pool, _rejected = rank_automatic_candidates(
candidate_pool, artist, title, priority_source=priority_source
)
if not candidate_pool:
failure = locals().get("failure", "No suitable rescue candidates found")
with db_conn() as conn:
conn.execute(
"""UPDATE jobs SET status = 'failed', error = ?, progress_stage = NULL,
completed_at = datetime('now') WHERE id = ?""",
(failure, new_job_id),
)
conn.execute(
"""UPDATE bulk_import_tracks SET status = 'failed', error = ?
WHERE acquisition_target_id = ?""",
(failure, target_id),
)
conn.commit()
finish_acquisition_cycle(target_id, "failed", failure)
return
save_search_decision(
new_job_id,
f"{artist} - {title}".strip(" -"),
acquisition_candidate_summary(candidate_pool[0]),
[acquisition_candidate_summary(item) for item in candidate_pool[1:4]],
)
else:
candidate_pool = [candidate for candidate in candidates if _candidate_can_download(candidate)]
if not candidate_pool:
failure = "The selected candidate no longer has enough download information"
with db_conn() as conn:
conn.execute(
"UPDATE jobs SET status = 'failed', error = ?, progress_stage = NULL WHERE id = ?",
(failure, new_job_id),
)
conn.execute(
"""UPDATE bulk_import_tracks SET status = 'failed', error = ?
WHERE acquisition_target_id = ?""",
(failure, target_id),
)
conn.commit()
finish_acquisition_cycle(target_id, "failed", failure)
return
playlist_name, use_playlists_dir, custom_subdir = _rescue_routing(job, destination)
process_acquisition_cycle(
new_job_id,
candidate_pool[0],
artist,
title,
bool(job.get("target_convert_audio")),
target_id=target_id,
cycle=cycle,
automatic=automatic,
candidate_pool=candidate_pool,
allowed_sources=allowed_sources if automatic else {
(candidate_pool[0].get("source") or "youtube").lower()
},
priority_source=priority_source if automatic else candidate_pool[0].get("source"),
playlist_name=playlist_name,
use_playlists_dir=use_playlists_dir,
user_id=job.get("target_user_id") or job.get("user_id"),
override_dir=destination.get("override_dir") or job.get("override_dir"),
skip_dupe_check=bool(destination.get("override_dir") or job.get("override_dir")),
custom_subdir=custom_subdir,
)
with db_conn() as conn:
state_row = conn.execute(
"SELECT status, error FROM jobs WHERE id = ?", (new_job_id,)
).fetchone()
if state_row:
track_status = (
"completed"
if state_row[0] in ("completed", "completed_with_errors")
else "cancelled" if state_row[0] == "cancelled" else "failed"
)
conn.execute(
"""UPDATE bulk_import_tracks SET status = ?, error = ?
WHERE acquisition_target_id = ?""",
(
track_status,
None if track_status == "completed" else state_row[1],
target_id,
),
)
conn.commit()
@app.get("/api/jobs/{job_id}/rescue-candidates")
def get_job_rescue_candidates(job_id: str, http_request: Request):
job, _destination = _rescue_context(
job_id, http_request.state.user_id, http_request.state.is_admin
)
candidates = _rescue_candidates_for_target(
job["acquisition_target_id"], parse_allowed_sources(job.get("allowed_sources"))
)
return {
"artist": job.get("target_artist") or job.get("artist") or "",
"title": job.get("target_title") or job.get("title") or "",
"candidates": candidates,
}
@app.post("/api/jobs/{job_id}/rescue-candidates/refresh")
def refresh_job_rescue_candidates(job_id: str, http_request: Request):
job, _destination = _rescue_context(
job_id, http_request.state.user_id, http_request.state.is_admin
)
artist = job.get("target_artist") or job.get("artist") or ""
title = job.get("target_title") or job.get("title") or ""
try:
ranked = _search_rescue_candidates(job, SEARCH_SLOT_WAIT_INTERACTIVE)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Candidate search failed: {exc}")
if ranked:
save_search_decision(
job_id,
f"{artist} - {title}".strip(" -"),
acquisition_candidate_summary(ranked[0]),
[acquisition_candidate_summary(item) for item in ranked[1:4]],
)
return get_job_rescue_candidates(job_id, http_request)
@app.post("/api/jobs/{job_id}/retry-next")
def retry_job_next_option(job_id: str, http_request: Request):
job, destination = _rescue_context(
job_id, http_request.state.user_id, http_request.state.is_admin
)
candidates = _rescue_candidates_for_target(
job["acquisition_target_id"], parse_allowed_sources(job.get("allowed_sources"))
)
new_job_id, cycle = _create_rescue_job(job, destination)
spawn_daemon_thread(
_run_rescue_cycle, job, destination, new_job_id, cycle, candidates, True
)
return {"job_id": new_job_id, "status": "queued"}
@app.post("/api/jobs/{job_id}/rescue-candidate")
def queue_job_rescue_candidate(
job_id: str, body: QueueRescueCandidateRequest, http_request: Request
):
job, destination = _rescue_context(
job_id, http_request.state.user_id, http_request.state.is_admin
)
allowed = parse_allowed_sources(job.get("allowed_sources"))
candidates = _rescue_candidates_for_target(job["acquisition_target_id"], allowed)
candidate = next(
(item for item in candidates if item.get("candidate_key") == body.candidate_key),
None,
)
if not candidate:
raise HTTPException(status_code=404, detail="Stored candidate not found")
if not candidate.get("selectable"):
raise HTTPException(status_code=400, detail="Candidate download details have expired; search again")
new_job_id, cycle = _create_rescue_job(job, destination, candidate)
spawn_daemon_thread(
_run_rescue_cycle, job, destination, new_job_id, cycle, [candidate], False
)
return {"job_id": new_job_id, "status": "queued"}
@app.post("/api/jobs/{job_id}/force-accept")
def force_accept_job(job_id: str, http_request: Request):
"""Set skip_mismatch_check on a job and retry it. Handy shortcut from the queue card.
Ownership is checked before anything is written. Knowing someone else's
eight-character job ID should not be enough to wipe their mismatch evidence,
even if the retry itself would have been refused a moment later.
"""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
scope_frag, scope_params = _user_scope(user_id, is_admin)
with db_conn() as conn:
owned = conn.execute(
f"SELECT 1 FROM jobs WHERE id = ? AND {scope_frag}", (job_id, *scope_params)
).fetchone()
if not owned:
raise HTTPException(status_code=404, detail="Job not found")
conn.execute(
"UPDATE jobs SET skip_mismatch_check = 1 WHERE id = ?", (job_id,)
)
# Tidy up any mismatch log entries for this job while we're at it
conn.execute(
"DELETE FROM watched_match_mismatches WHERE job_id = ?", (job_id,)
)
conn.commit()
# Delegate to the normal retry flow (which will now honour the skip flag)
return retry_job(job_id, http_request)
@app.delete("/api/jobs/{job_id}/file")
def delete_job_file(job_id: str, http_request: Request):
"""Delete the downloaded audio file (and lyrics) for a completed job."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
cursor = conn.execute(
f"SELECT * FROM jobs WHERE id = ? AND {_scope_frag}",
(job_id, *_scope_params)
)
row = cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Job not found")
job = dict(row)
if job["status"] not in ("completed", "completed_with_errors"):
raise HTTPException(status_code=400, detail="Only completed jobs have files to delete")
artist = job.get("artist")
title = job.get("title")
if not artist or not title:
raise HTTPException(status_code=400, detail="Job has no artist/title metadata")
from utils import check_duplicate, move_to_trash
existing = authoritative_audio_file(job.get("final_path"), user_id)
if existing is None:
existing = check_duplicate(artist, title, user_id=user_id)
if not existing:
# File already gone, just mark it and move on
with db_conn() as conn:
conn.execute("UPDATE jobs SET file_deleted = 1 WHERE id = ?", (job_id,))
conn.commit()
return {"deleted": [], "job_id": job_id, "trashed": False}
# Move the file to the trash bin. Covers both playlist-owned and library files;
# the whole point of the trash is that nothing gets permanently nuked, so the
# user can restore without re-downloading.
trashed_files = []
trash_result = move_to_trash(existing, user_id=user_id)
if trash_result:
trashed_files.append(existing.name)
lrc_trash = trash_result.with_suffix(".lrc")
if lrc_trash.exists():
trashed_files.append(lrc_trash.name)
else:
# Trash failed; fall back to permanent delete so we don't leave the user stuck
try:
existing.unlink()
trashed_files.append(existing.name)
lrc_file = existing.with_suffix(".lrc")
if lrc_file.exists():
lrc_file.unlink()
trashed_files.append(lrc_file.name)
# Clean up empty parent directory
track_dir = existing.parent
if track_dir.exists() and not any(track_dir.iterdir()):
track_dir.rmdir()
except OSError as e:
raise HTTPException(status_code=500, detail=f"Failed to delete: {e}")
with db_conn() as conn:
conn.row_factory = sqlite3.Row
conn.execute("UPDATE jobs SET file_deleted = 1 WHERE id = ?", (job_id,))
# Clear downloaded_at so the track reappears as missing and can be retried/replaced.
conn.execute(
"UPDATE watched_playlist_tracks SET downloaded_at = NULL WHERE job_id = ?",
(job_id,)
)
# Fetch playlist info for M3U rebuild (if this job belongs to a watched playlist)
playlist_row = conn.execute(
"""SELECT wp.id, wp.name, wp.make_m3u, wp.use_playlists_dir, wp.sync_mode,
wp.custom_subdir, wp.user_id
FROM watched_playlists wp
JOIN bulk_imports bi ON bi.watch_playlist_id = wp.id
JOIN bulk_import_tracks bt ON bt.import_id = bi.id AND bt.job_id = ?
WHERE wp.make_m3u = 1
LIMIT 1""",
(job_id,)
).fetchone()
if not playlist_row:
# The bulk import trail can go cold; the watched track itself never does.
playlist_row = conn.execute(
"""SELECT wp.id, wp.name, wp.make_m3u, wp.use_playlists_dir, wp.sync_mode,
wp.custom_subdir, wp.user_id
FROM watched_playlist_tracks wpt
JOIN watched_playlists wp ON wp.id = wpt.playlist_id
WHERE wpt.job_id = ? AND wp.make_m3u = 1
LIMIT 1""",
(job_id,)
).fetchone()
conn.commit()
# Rebuild M3U to remove this track from the playlist file
if playlist_row:
# custom_subdir and user_id matter: without them a playlist routed to its
# own folder gets rebuilt against the default Playlists dir instead, which
# writes the M3U somewhere nobody is looking. Every other caller passes both.
rebuild_watched_playlist_m3u(
playlist_row["id"], playlist_row["name"],
use_playlists_dir=bool(playlist_row["use_playlists_dir"]),
sync_mode=playlist_row["sync_mode"] or "append",
user_id=playlist_row["user_id"],
custom_subdir=playlist_row["custom_subdir"] or None,
)
return {"deleted": trashed_files, "job_id": job_id, "trashed": bool(trashed_files)}
@app.patch("/api/jobs/{job_id}/tags")
def patch_job_tags(job_id: str, body: PatchTagsRequest, http_request: Request):
"""Correct the artist, title, and/or album tags on a completed download."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
new_artist = body.artist.strip()
new_title = body.title.strip()
new_album = body.album.strip()
new_album_artist = body.album_artist.strip()
new_year = body.year.strip()
new_track_number = body.track_number if body.track_number and body.track_number > 0 else None
new_track_total = body.track_total if body.track_total and body.track_total > 0 else None
if not new_artist or not new_title:
raise HTTPException(status_code=422, detail="Artist and title are required")
if new_track_number and new_track_total and new_track_number > new_track_total:
raise HTTPException(status_code=422, detail="Track number cannot be greater than total tracks")
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
row = conn.execute(
f"SELECT * FROM jobs WHERE id = ? AND {_scope_frag}",
(job_id, *_scope_params)
).fetchone()
rp_playlist = conn.execute(
"SELECT resolved_path FROM watched_playlist_tracks WHERE job_id = ? AND resolved_path IS NOT NULL LIMIT 1",
(job_id,)
).fetchone()
rp_artist = conn.execute(
"SELECT resolved_path FROM watched_artist_tracks WHERE job_id = ? AND resolved_path IS NOT NULL LIMIT 1",
(job_id,)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Job not found")
job = dict(row)
if job["status"] not in ("completed", "completed_with_errors"):
raise HTTPException(status_code=400, detail="Only completed jobs can be edited")
if job.get("file_deleted"):
raise HTTPException(status_code=400, detail="File has been deleted")
old_artist = job.get("artist") or ""
old_title = job.get("title") or ""
if not old_artist or not old_title:
raise HTTPException(status_code=400, detail="Job has no artist/title metadata")
# Resolve the file on disk, same priority order as delete_job_file
from utils import check_duplicate
file_path = authoritative_audio_file(job.get("final_path"), user_id)
for rp_row in (rp_playlist, rp_artist):
if file_path is not None:
break
if rp_row:
file_path = authoritative_audio_file(rp_row["resolved_path"], user_id)
if file_path is None:
file_path = check_duplicate(old_artist, old_title, user_id=user_id)
if not file_path or not file_path.exists():
raise HTTPException(status_code=404, detail="File not found on disk")
# Work out whether we need to rename (artist or title changed)
needs_rename = (new_artist != old_artist) or (new_title != old_title)
new_file_path = file_path
if needs_rename:
new_stem = f"{sanitize_filename(new_artist)} - {sanitize_filename(new_title)}"
new_file_path = file_path.parent / (new_stem + file_path.suffix)
if new_file_path != file_path and new_file_path.exists():
raise HTTPException(status_code=409, detail="A file with that artist and title already exists")
# Write tags first (in-place, file stays at old path)
try:
apply_metadata_to_file(
file_path,
artist=new_artist,
title=new_title,
album=new_album,
year=new_year or None,
track_number=new_track_number,
track_total=new_track_total,
album_artist=new_album_artist or None,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to write tags: {e}")
# Rename the file and sidecar if needed
if needs_rename and new_file_path != file_path:
try:
file_path.rename(new_file_path)
set_file_permissions(new_file_path)
old_lrc = file_path.with_suffix(".lrc")
if old_lrc.exists():
try:
old_lrc.rename(new_file_path.with_suffix(".lrc"))
set_file_permissions(new_file_path.with_suffix(".lrc"))
except OSError:
pass # Stranded .lrc is a minor nuisance, not worth failing the whole request
except OSError as e:
raise HTTPException(status_code=500, detail=f"Tags written but rename failed: {e}")
# Update the database now that disk is consistent
resolved_str = str(new_file_path)
with db_conn() as conn:
conn.execute(
"UPDATE jobs SET artist = ?, title = ?, album_name = ?, final_path = ? WHERE id = ?",
(new_artist, new_title, new_album, resolved_str, job_id)
)
if rp_playlist:
conn.execute(
"UPDATE watched_playlist_tracks SET resolved_path = ? WHERE job_id = ?",
(resolved_str, job_id)
)
if rp_artist:
conn.execute(
"UPDATE watched_artist_tracks SET resolved_path = ? WHERE job_id = ?",
(resolved_str, job_id)
)
conn.commit()
# The index holds the artist and title this file used to carry, and duplicate
# detection trusts them: leave the old row standing and a later download of
# some unrelated track gets refused as a duplicate of a name this file no
# longer has. So the row is removed and read again rather than relocated,
# because a retag changed the bytes and not merely the name. Unconditionally,
# even when nothing was renamed, since an album correction moves no file and
# the arrival hook skips re-reading a file whose size and timestamp have not
# budged; without this it would happily serve the old album forever.
library_index.remove_file(file_path)
library_index.index_file(new_file_path)
# Fire-and-forget rescan so library managers pick up the rename/retag
spawn_daemon_thread(trigger_navidrome_scan, user_id=user_id)
spawn_daemon_thread(trigger_jellyfin_scan, user_id=user_id)
spawn_daemon_thread(trigger_plex_scan, user_id=user_id)
return {
"success": True,
"artist": new_artist,
"title": new_title,
"album": new_album,
"album_artist": new_album_artist,
"year": new_year,
"track_number": new_track_number,
"track_total": new_track_total,
}
@app.get("/api/jobs/{job_id}/musicbrainz-guess")
def get_job_musicbrainz_guess(job_id: str, artist: str, title: str, http_request: Request):
"""Return a best-effort MusicBrainz tag guess for a queue item."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
artist = (artist or "").strip()
title = (title or "").strip()
offset_raw = (http_request.query_params.get("offset") or "0").strip()
if not artist or not title:
raise HTTPException(status_code=422, detail="Artist and title are required")
try:
offset = int(offset_raw)
except ValueError:
raise HTTPException(status_code=422, detail="Invalid guess offset")
if offset < 0:
raise HTTPException(status_code=422, detail="Invalid guess offset")
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
row = conn.execute(
f"SELECT id, status, file_deleted, final_path FROM jobs WHERE id = ? AND {_scope_frag}",
(job_id, *_scope_params)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Job not found")
if row["status"] not in ("completed", "completed_with_errors"):
raise HTTPException(status_code=400, detail="Only completed jobs can be edited")
if row["file_deleted"]:
raise HTTPException(status_code=400, detail="File has been deleted")
from utils import check_duplicate
file_path = authoritative_audio_file(row["final_path"], user_id)
if file_path is None:
candidate = check_duplicate(artist, title, user_id=user_id)
if candidate and candidate.exists():
file_path = candidate
guess = guess_musicbrainz_tags(artist, title, offset=offset, file_path=file_path)
if not guess:
raise HTTPException(status_code=404, detail="No more suitable MusicBrainz matches found")
return guess
@app.delete("/api/jobs/cleanup")
def cleanup_jobs(
http_request: Request,
status: Optional[str] = None,
confirm: bool = False,
):
"""Delete completed, failed, or stale jobs.
Admins clear the lot; standard users only clear their own rows.
Peons get bounced; the UI hides the button but a curious one with dev
tools open shouldn't get to nuke anything. The confirmation query parameter
is deliberately enforced by the API as well as the browser prompt: scripts
and test suites should not be able to erase Queue history by accident.
"""
user = getattr(http_request.state, "user", None) or {}
if user.get("role") == "peon":
raise HTTPException(status_code=403, detail="Peons cannot clear the queue")
if not confirm:
raise HTTPException(
status_code=400,
detail="Confirmation required (use ?confirm=true)",
)
if status not in (None, "both", "completed", "failed", "stale"):
raise HTTPException(
status_code=400,
detail="Invalid status (use completed, failed, stale, or both)",
)
# First, mark any stale jobs as failed so they get cleaned up
cleanup_stale_jobs()
is_admin = http_request.state.is_admin
user_id = http_request.state.user_id
scope_clause = "" if is_admin else " AND user_id = ?"
scope_params: tuple = () if is_admin else (user_id,)
with db_conn() as conn:
if status == "completed":
cursor = conn.execute(
f"DELETE FROM jobs WHERE status IN ('completed', 'completed_with_errors'){scope_clause}",
scope_params,
)
elif status == "failed":
cursor = conn.execute(
f"DELETE FROM jobs WHERE status = 'failed'{scope_clause}",
scope_params,
)
elif status == "stale":
cursor = conn.execute(
f"DELETE FROM jobs WHERE status IN ('downloading', 'queued') "
f"AND created_at < datetime('now', ? || ' seconds'){scope_clause}",
(str(-STALE_JOB_TIMEOUT), *scope_params),
)
else: # Omitted or explicitly "both".
cursor = conn.execute(
f"DELETE FROM jobs WHERE status IN ('completed', 'completed_with_errors', 'failed'){scope_clause}",
scope_params,
)
deleted_count = cursor.rowcount
conn.commit()
return {"deleted": deleted_count}
# =============================================================================
# Trash Bin API
# =============================================================================
@app.get("/api/trash")
def list_trash(http_request: Request):
"""List all files sitting in the trash bin."""
from settings import get_trash_dir
user_id = http_request.state.user_id
trash_dir = get_trash_dir(user_id=user_id)
if not trash_dir.exists():
return {"files": [], "total_size": 0}
files = []
total_size = 0
walk_errors = []
def _on_walk_error(err):
walk_errors.append(str(err))
print(f"[trash] Failed to read {getattr(err, 'filename', trash_dir)}: {err}")
for dirpath, _dirnames, filenames in os.walk(trash_dir, onerror=_on_walk_error):
for fname in filenames:
fp = Path(dirpath) / fname
if fp.suffix.lower() == ".lrc":
continue
try:
rel = fp.relative_to(trash_dir)
stat = fp.stat()
size = stat.st_size
total_size += size
files.append({
"path": str(rel),
"name": fp.stem,
"ext": fp.suffix,
"size": size,
"modified": stat.st_mtime,
})
except OSError as e:
print(f"[trash] Failed to inspect {fp}: {e}")
continue
if walk_errors and not files:
raise HTTPException(status_code=500, detail="Trash exists but could not be read")
# Sort newest first so the most recently trashed files appear at the top
files.sort(key=lambda f: f["modified"], reverse=True)
return {"files": files, "total_size": total_size}
@app.get("/api/trash/stream")
def stream_trash_file(http_request: Request, path: str = ""):
"""Stream a trashed audio file for in-browser playback. Have a listen before you decide."""
from settings import get_trash_dir
user_id = http_request.state.user_id
if not path:
raise HTTPException(status_code=400, detail="No path specified")
trash_dir = get_trash_dir(user_id=user_id)
trash_path = trash_dir / path
try:
trash_path.resolve().relative_to(trash_dir.resolve())
except ValueError:
raise HTTPException(status_code=400, detail="Invalid path")
if not trash_path.exists():
raise HTTPException(status_code=404, detail="File not found in trash")
mime = _AUDIO_MIME_TYPES.get(trash_path.suffix.lower(), "audio/mpeg")
return FileResponse(path=str(trash_path), media_type=mime)
def _complete_restored_failed_job(
restored: Path, user_id: str | None, is_admin: bool, artist: str, title: str
) -> str | None:
"""Mark the one failed Queue job represented by a restored audio file complete.
Newer rejected downloads keep their intended final path, which is an exact
match after Restore. Older rows did not record that path, so they get a
conservative artist/title fallback only when it identifies one job.
"""
with db_conn() as conn:
conn.row_factory = sqlite3.Row
scope_frag, scope_params = _user_scope(user_id, is_admin)
rows = conn.execute(
f"""SELECT id FROM jobs
WHERE status = 'failed' AND final_path = ? AND {scope_frag}""",
(str(restored), *scope_params),
).fetchall()
if not rows and artist and title:
rows = conn.execute(
f"""SELECT id FROM jobs
WHERE status = 'failed'
AND (final_path IS NULL OR final_path = '')
AND artist = ? COLLATE NOCASE
AND title = ? COLLATE NOCASE
AND {scope_frag}
LIMIT 2""",
(artist, title, *scope_params),
).fetchall()
if len(rows) != 1:
return None
job_id = rows[0]["id"]
conn.execute(
"""UPDATE jobs
SET status = 'completed', error = NULL, final_path = ?,
file_deleted = 0, progress_stage = NULL, completed_at = datetime('now')
WHERE id = ?""",
(str(restored), job_id),
)
conn.commit()
return job_id
@app.post("/api/trash/restore")
def restore_trash_file(http_request: Request, path: str = ""):
"""Restore a specific file from the trash to its original library location."""
from utils import restore_from_trash
from settings import get_trash_dir
user_id = http_request.state.user_id
if not path:
raise HTTPException(status_code=400, detail="No path specified")
trash_dir = get_trash_dir(user_id=user_id)
trash_path = trash_dir / path
# Safety: make sure the resolved path is actually inside the trash dir
try:
trash_path.resolve().relative_to(trash_dir.resolve())
except ValueError:
raise HTTPException(status_code=400, detail="Invalid path")
if not trash_path.exists():
raise HTTPException(status_code=404, detail="File not found in trash")
restored = restore_from_trash(trash_path, user_id=user_id)
if not restored:
raise HTTPException(status_code=500, detail="Failed to restore file")
# A restored rejected download is a deliberate acceptance of that version.
# Keep the Queue and watched-track state honest instead of leaving a red failed
# card behind for a file the user has explicitly put back in their library.
restored_job_id = None
restored_artist = restored_title = ""
try:
from metadata import read_artist_title
restored_artist, restored_title = read_artist_title(restored)
restored_job_id = _complete_restored_failed_job(
restored, user_id, False, restored_artist, restored_title
)
if restored_job_id:
from downloads import _mark_watched_track_downloaded, _mark_watched_artist_track_downloaded
_mark_watched_track_downloaded(restored_job_id, resolved_path=restored, skip_mismatch=True)
_mark_watched_artist_track_downloaded(restored_job_id, resolved_path=restored)
except Exception as e:
# The restore itself already succeeded. A queue-reconciliation hiccup must
# not pretend otherwise or trap the file in the bin.
print(f"Restore Queue reconciliation failed for {restored}: {e}")
# Re-stamp the playlist COMMENT tag: a restore just moves the file back, so a
# track that was wrongly binned would otherwise come back untagged until the
# next manual playlist refresh. Read the artist/title off the file and let the
# shared helper (a no-op when the setting's off) put the playlist names back.
try:
from downloads import _tag_track_comment
if restored_artist and restored_title:
_tag_track_comment(restored, restored_artist, restored_title)
except Exception as e:
print(f"Restore COMMENT re-tag failed for {restored}: {e}")
# Abandoning an album repair must put the old copy back into its M3Us too.
try:
album_relative = restored.parent.resolve().relative_to(get_albums_dir(user_id=user_id).resolve())
except ValueError:
album_relative = None
if album_relative is not None and len(album_relative.parts) == 2:
with db_conn() as conn:
conn.execute("UPDATE jobs SET file_deleted = 0 WHERE final_path = ? AND user_id IS ?", (str(restored), user_id))
conn.commit()
_refresh_album_m3u_if_present(str(restored.parent))
return {"restored": str(restored.name), "path": str(restored), "job_id": restored_job_id}
@app.delete("/api/trash")
def empty_trash(http_request: Request):
"""Permanently delete everything in your own trash bin. No coming back from this one.
Bins are per-account now, so this empties the caller's, not the whole house's.
No admin gate: that made sense when the bin was one shared heap, but a bin
you cannot empty is a bin with a lock on the lid. Same trust model as
/api/trash/file, which has always let you bin your own one at a time.
"""
from settings import get_trash_dir
import shutil
user_id = http_request.state.user_id
trash_dir = get_trash_dir(user_id=user_id)
if not trash_dir.exists():
return {"deleted": 0}
count = sum(1 for _ in trash_dir.rglob("*") if _.is_file())
shutil.rmtree(trash_dir, ignore_errors=True)
return {"deleted": count}
@app.delete("/api/trash/file")
def delete_trash_file(http_request: Request, path: str = ""):
"""Permanently delete a single file from the trash. Truly gone this time."""
from settings import get_trash_dir
user_id = http_request.state.user_id
if not path:
raise HTTPException(status_code=400, detail="No path specified")
trash_dir = get_trash_dir(user_id=user_id)
trash_path = trash_dir / path
try:
trash_path.resolve().relative_to(trash_dir.resolve())
except ValueError:
raise HTTPException(status_code=400, detail="Invalid path")
if not trash_path.exists():
raise HTTPException(status_code=404, detail="File not found in trash")
try:
trash_path.unlink()
# Also remove the lyrics sidecar if present
lrc = trash_path.with_suffix(".lrc")
if lrc.exists():
lrc.unlink()
# Clean up empty directories
parent = trash_path.parent
while parent != trash_dir and str(parent).startswith(str(trash_dir)):
try:
if parent.exists() and not any(parent.iterdir()):
parent.rmdir()
except OSError:
break
parent = parent.parent
except OSError as e:
raise HTTPException(status_code=500, detail=f"Failed to delete: {e}")
return {"deleted": trash_path.name}
# =============================================================================
# Blacklist / Report API
# =============================================================================
@app.post("/api/blacklist")
def add_blacklist_entry(request: BlacklistRequest, http_request: Request):
"""Report a bad track and/or block an uploader."""
if not http_request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
if not request.video_id and not request.uploader:
raise HTTPException(status_code=400, detail="Need at least a video_id or uploader to blacklist")
entries_created = []
with db_conn() as conn:
# Blacklist the specific video
if request.video_id:
# Upsert - if the same video_id is already blacklisted, update the reason
existing = conn.execute(
"SELECT id FROM blacklist WHERE video_id = ?", (request.video_id,)
).fetchone()
if existing:
conn.execute(
"UPDATE blacklist SET reason = ?, note = ?, job_id = COALESCE(?, job_id) WHERE id = ?",
(request.reason, request.note, request.job_id, existing[0])
)
entries_created.append({"type": "video", "id": existing[0], "updated": True})
else:
cursor = conn.execute(
"INSERT INTO blacklist (video_id, source, reason, note, job_id) VALUES (?, ?, ?, ?, ?)",
(request.video_id, request.source, request.reason, request.note, request.job_id)
)
entries_created.append({"type": "video", "id": cursor.lastrowid})
# Optionally blacklist the uploader too
if request.block_uploader and request.uploader:
uploader_lower = request.uploader.lower()
existing = conn.execute(
"SELECT id FROM blacklist WHERE lower(uploader) = ? AND source = ? AND (video_id IS NULL OR video_id = '')",
(uploader_lower, request.source)
).fetchone()
if not existing:
cursor = conn.execute(
"INSERT INTO blacklist (uploader, source, reason, note, job_id) VALUES (?, ?, ?, ?, ?)",
(request.uploader, request.source, request.reason, request.note, request.job_id)
)
entries_created.append({"type": "uploader", "id": cursor.lastrowid})
conn.commit()
clear_automated_search_cache()
return {"entries": entries_created}
@app.get("/api/blacklist")
def list_blacklist(http_request: Request, limit: int = 100, offset: int = 0):
"""List all blacklist entries for the management UI."""
if not http_request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
with db_conn() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM blacklist ORDER BY created_at DESC LIMIT ? OFFSET ?",
(limit, offset)
).fetchall()
total = conn.execute("SELECT COUNT(*) FROM blacklist").fetchone()[0]
return {
"entries": [dict(r) for r in rows],
"total": total
}
@app.delete("/api/blacklist/{entry_id}")
def remove_blacklist_entry(entry_id: int, http_request: Request):
"""Remove a blacklist entry by ID."""
if not http_request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
with db_conn() as conn:
cursor = conn.execute("DELETE FROM blacklist WHERE id = ?", (entry_id,))
if cursor.rowcount == 0:
raise HTTPException(status_code=404, detail="Blacklist entry not found")
conn.commit()
clear_automated_search_cache()
return {"deleted": entry_id}
# =============================================================================
# Bulk Import API
# =============================================================================
@app.post("/api/bulk-import-async")
def bulk_import_async(body: AsyncBulkImportRequest, http_request: Request):
"""Start an async bulk import job"""
_enforce_peon_format(http_request, body)
lines = body.songs.strip().split('\n')
import_id = str(uuid.uuid4())[:8]
user_id = http_request.state.user_id
# Parse and validate all lines first
tracks_to_import = []
for line_num, line in enumerate(lines, 1):
line = clean_bulk_import_line(line)
if not line:
continue
if len(line) > 200:
continue
# Try to parse "Artist - Song" format
match = re.match(r'^(.+?)\s*[-–—]\s*(.+)$', line)
if not match:
continue
artist, song = match.groups()
artist = artist.strip()
song = song.strip()
if not artist or not song:
continue
tracks_to_import.append({
"line_num": line_num,
"artist": artist,
"song": song
})
if not tracks_to_import:
raise HTTPException(status_code=400, detail="No valid tracks found in input")
# Normalise priority_source: empty / "any" / "all" all mean "no preference".
_priority_source = (body.priority_source or "").strip().lower() or None
if _priority_source in ("any", "all", "none"):
_priority_source = None
_preferred_sources = (body.preferred_sources or "all").strip().lower() or "all"
if _preferred_sources in ("any", "none"):
_preferred_sources = "all"
_playlist_name = body.playlist_name
if body.create_playlist:
_playlist_name = sanitize_playlist_name(body.playlist_name, body.playlist_source_url or import_id)
# Create bulk import record
with db_conn() as conn:
conn.execute(
"""INSERT INTO bulk_imports
(id, status, total_tracks, create_playlist, playlist_name, convert_audio, use_playlists_dir, user_id, preferred_sources, priority_source)
VALUES (?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?)""",
(import_id, len(tracks_to_import), int(body.create_playlist),
_playlist_name, int(body.convert_audio), int(body.use_playlists_dir), user_id, _preferred_sources, _priority_source)
)
# Insert all tracks
for track_index, track in enumerate(tracks_to_import):
target_id = ensure_acquisition_target(
owner_type="bulk_import",
owner_key=f"{import_id}:{track['line_num']}",
mode="automatic",
artist=track["artist"],
title=track["song"],
user_id=user_id,
allowed_sources=_preferred_sources,
priority_source=_priority_source,
convert_audio=body.convert_audio,
destination={
"create_playlist": bool(body.create_playlist),
"playlist_name": _playlist_name,
"use_playlists_dir": bool(body.use_playlists_dir),
},
conn=conn,
)
conn.execute(
"""INSERT INTO bulk_import_tracks
(import_id, line_num, artist, song, status,
original_youtube_video_id, acquisition_target_id)
VALUES (?, ?, ?, ?, 'pending', ?, ?)""",
(
import_id, track["line_num"], track["artist"],
track["song"],
(
body.original_youtube_video_ids[track_index]
if body.original_youtube_video_ids
and track_index < len(body.original_youtube_video_ids)
else None
),
target_id,
)
)
conn.commit()
# Start background worker for this import
spawn_daemon_thread(process_bulk_import_worker, import_id)
return {
"import_id": import_id,
"total_tracks": len(tracks_to_import),
"status": "pending"
}
@app.get("/api/bulk-import/{import_id}/status")
def get_bulk_import_status(import_id: str, http_request: Request):
"""Get status of a bulk import job"""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
cursor = conn.execute(
f"SELECT * FROM bulk_imports WHERE id = ? AND {_scope_frag}",
(import_id, *_scope_params)
)
import_row = cursor.fetchone()
if not import_row:
raise HTTPException(status_code=404, detail="Import not found")
import_row = _expose_conversion_intent(dict(import_row))
# Every track in input order, job_id included. The frontend uses job_id
# to fetch the real job cards (GET /api/jobs?import_id=...) for anything
# already queued, and falls back to a plain "searching" row for anything
# that hasn't reached a job yet.
cursor = conn.execute(
"""SELECT artist, song, status, error, job_id FROM bulk_import_tracks
WHERE import_id = ?
ORDER BY line_num ASC""",
(import_id,)
)
tracks = [dict(row) for row in cursor.fetchall()]
# Count download statuses by joining bulk_import_tracks with jobs.
# dupe_skipped: completed jobs whose error field starts with "Already exists"
# (the duplicate-skip path in process_download marks the job completed and
# stuffs a human-readable reason into error). It's the only API-visible
# signal that the library dupe check actually short-circuited the work.
cursor = conn.execute(
"""SELECT
SUM(CASE WHEN j.status IN ('completed', 'completed_with_errors') THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN j.status = 'failed' THEN 1 ELSE 0 END) as download_failed,
SUM(CASE WHEN j.status IN ('queued', 'downloading') THEN 1 ELSE 0 END) as still_queued,
SUM(CASE WHEN j.status IN ('completed', 'completed_with_errors')
AND (j.error LIKE 'Already exists%' OR j.error LIKE 'Already exists in %')
THEN 1 ELSE 0 END) as dupe_skipped
FROM bulk_import_tracks t
JOIN jobs j ON t.job_id = j.id
WHERE t.import_id = ?""",
(import_id,)
)
row = cursor.fetchone()
completed_count = row[0] or 0
download_failed_count = row[1] or 0
still_queued_count = row[2] or 0
dupe_skipped_count = row[3] or 0
total_failed = import_row["failed"] + download_failed_count
search_done = import_row["status"] in ("completed", "cancelled", "error")
all_done = search_done and still_queued_count == 0
return {
"import_id": import_id,
"status": import_row["status"],
"total_tracks": import_row["total_tracks"],
"searched": import_row["searched"],
"queued": still_queued_count,
"completed": completed_count,
"failed": total_failed,
"skipped": import_row["skipped"],
"dupe_skipped": dupe_skipped_count,
"rate_limited": import_row["rate_limited_until"] is not None,
"error": import_row["error"],
"tracks": tracks,
"complete": all_done
}
@app.post("/api/bulk-import/{import_id}/cancel")
def cancel_bulk_import(import_id: str, http_request: Request):
"""Cancel untouched import work and retain completed files/Queue rows."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
scope_frag, scope_params = _user_scope(user_id, is_admin)
import_row = conn.execute(
f"SELECT id, status FROM bulk_imports WHERE id = ? AND {scope_frag}",
(import_id, *scope_params),
).fetchone()
if not import_row:
raise HTTPException(status_code=404, detail="Import not found")
if import_row["status"] in ("completed", "cancelled", "error"):
return {"import_id": import_id, "status": import_row["status"], "already_finished": True}
result = request_bulk_import_cancellation(import_id, conn=conn)
status = conn.execute(
"SELECT status FROM bulk_imports WHERE id = ?", (import_id,)
).fetchone()[0]
conn.commit()
return {"import_id": import_id, "status": status, **result}
@app.get("/api/bulk-imports")
def list_bulk_imports(limit: int = 10, http_request: Request = None):
"""List recent bulk imports"""
user_id = http_request.state.user_id if http_request else None
is_admin = http_request.state.is_admin if http_request else True
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
cursor = conn.execute(
f"SELECT * FROM bulk_imports WHERE {_scope_frag} ORDER BY created_at DESC LIMIT ?",
(*_scope_params, limit)
)
imports = [_expose_conversion_intent(dict(row)) for row in cursor.fetchall()]
return {"imports": imports}
# =============================================================================
# Playlist Fetch API (Spotify, Amazon Music)
# =============================================================================
@app.post("/api/fetch-playlist")
@app.post("/api/spotify-playlist") # Backwards compat
def fetch_playlist(request: Request, body: PlaylistFetchRequest):
"""Fetch track list from a supported public playlist URL."""
url = body.url.strip()
platform, _ = detect_playlist_platform(url)
# fetch_playlist_tracks returns (artist, title) tuples - reformat to the
# "Artist - Title" strings the bulk import UI expects, plus a playlist name.
user_id = request.state.user_id
fetched = fetch_playlist_tracks(url, platform, user_id=user_id)
tracks_tuples, playlist_name, warning = fetched[:3]
original_video_ids = fetched[3] if len(fetched) > 3 else [None] * len(tracks_tuples)
playlist_name = sanitize_playlist_name(playlist_name, url)
tracks = [f"{artist} - {title}" for artist, title in tracks_tuples]
resp = {"tracks": tracks, "playlist_name": playlist_name, "count": len(tracks), "platform": platform}
if any(original_video_ids):
resp["original_youtube_video_ids"] = original_video_ids
if warning:
resp["warning"] = warning
return resp
# =============================================================================
# Long-form YouTube Splitting API
# =============================================================================
@app.post("/api/longform/detect")
def longform_detect(body: LongformDetectRequest, request: Request):
"""Probe a single YouTube video for long-form split eligibility. No download."""
user_id = request.state.user_id
return detect_longform_candidate(body.url.strip(), user_id=user_id)
@app.post("/api/longform/split")
def longform_split(body: LongformSplitRequest, request: Request):
"""Confirm a split: download the video once, then cut/tag each segment."""
user_id = request.state.user_id
segments = [s.model_dump() for s in body.segments]
error = validate_segments(segments)
if error:
raise HTTPException(status_code=400, detail=error)
split_id = create_longform_split(
body.url.strip(), body.video_title.strip(), body.album_artist.strip(), segments, user_id=user_id,
)
return {"split_id": split_id, "total_segments": len(segments), "status": "pending"}
@app.get("/api/longform/{split_id}/status")
def longform_status(split_id: str, http_request: Request):
"""Poll a long-form split's progress."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
scope_frag, scope_params = _user_scope(user_id, is_admin)
split_row = conn.execute(
f"SELECT * FROM longform_splits WHERE id = ? AND {scope_frag}",
(split_id, *scope_params),
).fetchone()
if not split_row:
raise HTTPException(status_code=404, detail="Split not found")
segments = conn.execute(
"SELECT segment_index, title, artist, status, error, final_path FROM longform_split_segments "
"WHERE split_id = ? ORDER BY segment_index",
(split_id,),
).fetchall()
split_row = dict(split_row)
complete = split_row["status"] in ("completed", "completed_with_errors", "failed", "cancelled")
return {
"split_id": split_id,
"status": split_row["status"],
"video_title": split_row["video_title"],
"total_segments": split_row["total_segments"],
"completed_segments": split_row["completed_segments"],
"failed_segments": split_row["failed_segments"],
"error": split_row["error"],
"segments": [dict(s) for s in segments],
"complete": complete,
}
@app.post("/api/longform/{split_id}/cancel")
def longform_cancel(split_id: str, http_request: Request):
"""Ask an in-progress split to stop before its next segment."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
scope_frag, scope_params = _user_scope(user_id, is_admin)
split_row = conn.execute(
f"SELECT id FROM longform_splits WHERE id = ? AND {scope_frag}",
(split_id, *scope_params),
).fetchone()
if not split_row:
raise HTTPException(status_code=404, detail="Split not found")
cancelled = cancel_longform_split(split_id)
return {"split_id": split_id, "cancelled": cancelled}
# =============================================================================
# Watched Playlists API
# =============================================================================
@app.post("/api/watched-playlists")
def add_watched_playlist(body: WatchedPlaylistRequest, http_request: Request):
"""Add a new playlist to watch for new tracks"""
_enforce_peon_format(http_request, body)
platform, platform_id = detect_playlist_platform(body.url)
user_id = http_request.state.user_id
# ListenBrainz username: fan out into one watched playlist per "Created for You" playlist
if platform == "listenbrainz_user":
lb_playlists = fetch_listenbrainz_createdfor(platform_id)
# LB playlists are weekly - mirror mode is the sensible default
sync_mode = "mirror"
# Weekly refresh makes sense since LB regenerates them weekly
refresh_hours = max(body.refresh_interval_hours, 168)
created = []
skipped = 0
with db_conn() as conn:
conn.row_factory = sqlite3.Row
for lb in lb_playlists:
existing = conn.execute(
"SELECT id FROM watched_playlists WHERE url = ? AND (user_id = ? OR (user_id IS NULL AND ? IS NULL))",
(lb["playlist_url"], user_id, user_id)
).fetchone()
if not existing:
# LB rotates playlist UUIDs weekly, so also match by username + name prefix
# (the part before ", week of YYYY-MM-DD") to avoid duplicate watched playlists.
name_prefix = lb["name"].split(", week of")[0]
existing = conn.execute(
"""SELECT id FROM watched_playlists
WHERE lb_username = ? AND name LIKE ?
AND (user_id = ? OR (user_id IS NULL AND ? IS NULL))""",
(platform_id, name_prefix + "%", user_id, user_id)
).fetchone()
if existing:
skipped += 1
continue
playlist_id = str(uuid.uuid4())[:8]
_lb_priority = (body.priority_source or "").strip().lower() or None
if _lb_priority in ("any", "all", "none"):
_lb_priority = None
conn.execute("""
INSERT INTO watched_playlists
(id, url, name, platform, refresh_interval_hours, last_checked, convert_audio, make_m3u, use_playlists_dir, sync_mode, last_track_count, user_id, preferred_sources, priority_source, lb_username, custom_subdir, redownload_missing_files)
VALUES (?, ?, ?, ?, ?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (playlist_id, lb["playlist_url"], lb["name"], "listenbrainz",
refresh_hours, int(body.convert_audio),
int(body.make_m3u), int(body.use_playlists_dir), sync_mode, len(lb["tracks"]), user_id,
body.preferred_sources or "all", _lb_priority, platform_id,
(body.custom_subdir or "").strip() or None,
None if body.redownload_missing_files is None else int(body.redownload_missing_files)))
for artist, title in lb["tracks"]:
track_hash = hash_track(artist, title)
conn.execute("""
INSERT OR IGNORE INTO watched_playlist_tracks
(playlist_id, track_hash, artist, title)
VALUES (?, ?, ?, ?)
""", (playlist_id, track_hash, artist, title))
created.append({"id": playlist_id, "name": lb["name"], "track_count": len(lb["tracks"])})
conn.commit()
if not created:
raise HTTPException(status_code=409, detail="All ListenBrainz 'Created for You' playlists are already being watched")
# Kick off bulk imports in background for each new playlist
for i, lb in enumerate([p for p in lb_playlists if any(c["name"] == p["name"] for c in created)]):
playlist_id = created[i]["id"]
if lb["tracks"]:
start_bulk_import_for_tracks(
lb["tracks"],
body.convert_audio,
watch_playlist_id=playlist_id,
use_playlists_dir=body.use_playlists_dir,
user_id=user_id,
preferred_sources=body.preferred_sources or "all",
priority_source=body.priority_source,
custom_subdir=(body.custom_subdir or "").strip() or None,
)
wake_scheduler()
return {
"created": len(created),
"skipped": skipped,
"playlists": created,
"message": f"Added {len(created)} ListenBrainz playlist(s)"
+ (f" ({skipped} already watched)" if skipped else ""),
}
with db_conn() as conn:
# Check for duplicate
conn.row_factory = sqlite3.Row
existing = conn.execute(
"SELECT id FROM watched_playlists WHERE url = ? AND (user_id = ? OR (user_id IS NULL AND ? IS NULL))",
(body.url, user_id, user_id)
).fetchone()
if existing:
raise HTTPException(status_code=409, detail="This playlist is already being watched")
# Fetch playlist to get name and initial tracks
try:
fetched = fetch_playlist_tracks(body.url, platform, user_id=user_id)
tracks, playlist_name, fetch_warning = fetched[:3]
original_video_ids = fetched[3] if len(fetched) > 3 else [None] * len(tracks)
playlist_name = sanitize_playlist_name(playlist_name, body.url)
except HTTPException:
raise
# Create playlist record
playlist_id = str(uuid.uuid4())[:8]
sync_mode = body.sync_mode if body.sync_mode in ("append", "mirror") else "append"
_new_priority = (body.priority_source or "").strip().lower() or None
if _new_priority in ("any", "all", "none"):
_new_priority = None
_new_preferred_sources = (
"soundcloud"
if platform == "soundcloud"
and (not body.preferred_sources or body.preferred_sources == "all")
else (body.preferred_sources or "all")
)
conn.execute("""
INSERT INTO watched_playlists
(id, url, name, platform, refresh_interval_hours, last_checked, convert_audio, make_m3u, use_playlists_dir, sync_mode, last_track_count, user_id, preferred_sources, priority_source, custom_subdir, redownload_missing_files)
VALUES (?, ?, ?, ?, ?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (playlist_id, body.url, playlist_name, platform,
body.refresh_interval_hours, int(body.convert_audio),
int(body.make_m3u), int(body.use_playlists_dir), sync_mode, len(tracks), user_id,
_new_preferred_sources,
_new_priority,
(body.custom_subdir or "").strip() or None,
None if body.redownload_missing_files is None else int(body.redownload_missing_files)))
# Insert all current tracks as "seen"
for index, (artist, title) in enumerate(tracks):
track_hash = hash_track(artist, title)
conn.execute("""
INSERT OR IGNORE INTO watched_playlist_tracks
(playlist_id, track_hash, artist, title, original_youtube_video_id)
VALUES (?, ?, ?, ?, ?)
""", (playlist_id, track_hash, artist, title, original_video_ids[index]))
conn.commit()
import_id = None
if tracks:
import_id = start_bulk_import_for_tracks(
tracks,
body.convert_audio,
watch_playlist_id=playlist_id,
use_playlists_dir=body.use_playlists_dir,
user_id=user_id,
preferred_sources=_new_preferred_sources,
priority_source=body.priority_source,
custom_subdir=(body.custom_subdir or "").strip() or None,
original_youtube_video_ids=original_video_ids,
)
resp = {
"id": playlist_id,
"name": playlist_name,
"platform": platform,
"track_count": len(tracks),
"refresh_interval_hours": body.refresh_interval_hours,
"import_id": import_id,
"message": f"Now watching '{playlist_name}' with {len(tracks)} tracks queued for download"
}
if fetch_warning:
resp["warning"] = fetch_warning
wake_scheduler()
return resp
@app.get("/api/watched-playlists")
def list_watched_playlists(http_request: Request):
"""List all watched playlists"""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
# Counts describe what the playlist contains NOW, not everything it has
# ever contained. A 50-track chart that churns weekly used to report
# hundreds of "tracks", which told the user nothing useful about either
# the playlist or their library. Departed tracks are counted separately;
# append mode keeps them, mirror mode has merely stopped listing them.
playlists = conn.execute(f"""
SELECT
wp.*,
(SELECT COUNT(*) FROM watched_playlist_tracks wpt
WHERE wpt.playlist_id = wp.id AND wpt.removed_at IS NULL) as tracked_count,
(SELECT COUNT(*) FROM watched_playlist_tracks wpt
WHERE wpt.playlist_id = wp.id AND wpt.removed_at IS NULL
AND wpt.downloaded_at IS NOT NULL) as downloaded_count,
(SELECT COUNT(*) FROM watched_playlist_tracks wpt
WHERE wpt.playlist_id = wp.id AND wpt.removed_at IS NOT NULL) as departed_count
FROM watched_playlists wp
WHERE {_scope_frag}
ORDER BY wp.created_at DESC
""", _scope_params).fetchall()
return {
"playlists": [_expose_conversion_intent(dict(p)) for p in playlists]
}
@app.get("/api/watched-playlists/schedule")
def get_watched_schedule():
"""Get the current watched playlist check schedule"""
return {
"check_interval_hours": WATCHED_PLAYLIST_CHECK_HOURS,
"enabled": WATCHED_PLAYLIST_CHECK_HOURS > 0
}
def _scoped_playlist_dicts(http_request: Request) -> list[dict]:
"""The caller's watched playlists as plain dicts (admins get the lot)."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
frag, params = _user_scope(user_id, is_admin)
rows = conn.execute(
f"""SELECT id, name, user_id, use_playlists_dir, custom_subdir, sync_mode
FROM watched_playlists WHERE {frag}""",
params,
).fetchall()
return [dict(r) for r in rows]
@app.get("/api/watched-playlists/orphans")
@prioritise_over_audio_audit
def list_playlist_orphans(http_request: Request):
"""Audio files sitting in playlist folders that no watched playlist claims.
Mirror-mode removals leave files behind by design; this rounds them up so the
user can decide whether to rehome them in Singles. Read-only; nothing moves.
"""
from watched_playlists import find_orphaned_playlist_files
return {"orphans": find_orphaned_playlist_files(_scoped_playlist_dicts(http_request))}
@app.post("/api/watched-playlists/orphans/move")
def move_playlist_orphans(request: OrphanMoveRequest, http_request: Request):
"""Move selected orphaned playlist files into the Singles layout.
Only files directly inside the caller's watched playlist folders are eligible;
anything else is skipped, not errored, so a stale UI list can't cause drama.
"""
if not request.files:
raise HTTPException(status_code=400, detail="No files given")
from watched_playlists import move_orphans_to_singles
return move_orphans_to_singles(request.files, _scoped_playlist_dicts(http_request))
@app.get("/api/watched-playlists/{playlist_id}")
def get_watched_playlist(playlist_id: str, http_request: Request):
"""Get details of a watched playlist including track history"""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
playlist = conn.execute(
f"SELECT * FROM watched_playlists WHERE id = ? AND {_scope_frag}",
(playlist_id, *_scope_params)
).fetchone()
if not playlist:
raise HTTPException(status_code=404, detail="Watched playlist not found")
tracks = conn.execute("""
SELECT * FROM watched_playlist_tracks
WHERE playlist_id = ?
ORDER BY first_seen DESC
""", (playlist_id,)).fetchall()
return {
"playlist": _expose_conversion_intent(dict(playlist)),
"tracks": [dict(t) for t in tracks]
}
@app.put("/api/watched-playlists/{playlist_id}")
def update_watched_playlist(playlist_id: str, request: WatchedPlaylistUpdate, http_request: Request):
"""Update watched playlist settings"""
if _is_peon(http_request):
request.convert_audio = None # Peons cannot change conversion setting
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
playlist = conn.execute(
f"SELECT * FROM watched_playlists WHERE id = ? AND {_scope_frag}",
(playlist_id, *_scope_params)
).fetchone()
if not playlist:
raise HTTPException(status_code=404, detail="Watched playlist not found")
# Build update query
updates = []
params = []
if request.refresh_interval_hours is not None:
updates.append("refresh_interval_hours = ?")
params.append(request.refresh_interval_hours)
if request.enabled is not None:
updates.append("enabled = ?")
params.append(int(request.enabled))
if request.enabled:
# Resuming gives the playlist a clean slate: drop any auto-pause
# note and reset the gone-strike counter so it isn't re-paused on
# the next single hiccup.
updates.append("auto_paused = 0")
updates.append("pause_reason = NULL")
updates.append("gone_strikes = 0")
if request.convert_audio is not None:
updates.append("convert_audio = ?")
params.append(int(request.convert_audio))
if request.make_m3u is not None:
updates.append("make_m3u = ?")
params.append(int(request.make_m3u))
if request.use_playlists_dir is not None:
updates.append("use_playlists_dir = ?")
params.append(int(request.use_playlists_dir))
if request.sync_mode is not None and request.sync_mode in ("append", "mirror"):
updates.append("sync_mode = ?")
params.append(request.sync_mode)
if request.preferred_sources is not None:
updates.append("preferred_sources = ?")
params.append(request.preferred_sources or "all")
if request.priority_source is not None:
# Empty / "any" / "all" / "none" all clear the override.
_p = (request.priority_source or "").strip().lower() or None
if _p in ("any", "all", "none"):
_p = None
updates.append("priority_source = ?")
params.append(_p)
if request.custom_subdir is not None:
updates.append("custom_subdir = ?")
params.append((request.custom_subdir or "").strip() or None)
if "redownload_missing_files" in request.model_fields_set:
updates.append("redownload_missing_files = ?")
params.append(
None if request.redownload_missing_files is None
else int(request.redownload_missing_files)
)
if updates:
params.append(playlist_id)
conn.execute(
f"UPDATE watched_playlists SET {', '.join(updates)} WHERE id = ?",
params
)
conn.commit()
# Fetch updated record
updated = conn.execute(
f"SELECT * FROM watched_playlists WHERE id = ? AND {_scope_frag}",
(playlist_id, *_scope_params)
).fetchone()
wake_scheduler()
return {"playlist": dict(updated)}
@app.delete("/api/watched-playlists/{playlist_id}")
def delete_watched_playlist(playlist_id: str, http_request: Request):
"""Remove a watched playlist and its track history"""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
playlist = conn.execute(
f"SELECT * FROM watched_playlists WHERE id = ? AND {_scope_frag}",
(playlist_id, *_scope_params)
).fetchone()
if not playlist:
raise HTTPException(status_code=404, detail="Watched playlist not found")
# Cancellation and deletion share this transaction. The worker can see
# the cancellation flag before the playlist row disappears, so it may
# finish its active files but cannot schedule the untouched tail.
cancelled = request_bulk_import_cancellation(
watch_playlist_id=playlist_id,
conn=conn,
)
# Delete tracks first (FK constraint)
conn.execute("DELETE FROM watched_playlist_tracks WHERE playlist_id = ?", (playlist_id,))
conn.execute("DELETE FROM watched_playlists WHERE id = ?", (playlist_id,))
conn.commit()
wake_scheduler()
return {
"message": f"Deleted watched playlist '{playlist['name']}'",
"cancelled_imports": cancelled["imports"],
"cancelled_jobs": cancelled["jobs"],
}
@app.get("/api/watched-playlists/{playlist_id}/missing")
def get_missing_watched_tracks(playlist_id: str, http_request: Request):
"""Return tracks that were never successfully downloaded for a watched playlist.
A track is 'missing' if it has no downloaded_at timestamp and either has no job,
or its job ended in failure. Tracks still actively queued or downloading are excluded.
In mirror mode, a track that has left the upstream playlist is not missing, it
is simply gone, so it is excluded; chasing it would be downloading something
the playlist no longer asks for. Append mode still lists them, because there
it is history it deliberately keeps and can still fill in.
"""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
playlist = conn.execute(
f"SELECT name, sync_mode FROM watched_playlists WHERE id = ? AND {_scope_frag}",
(playlist_id, *_scope_params)
).fetchall()
playlist = playlist[0] if playlist else None
if not playlist:
raise HTTPException(status_code=404, detail="Watched playlist not found")
_departed_frag = (
"AND wpt.removed_at IS NULL"
if (playlist["sync_mode"] or "append") == "mirror" else ""
)
rows = conn.execute(
f"""SELECT wpt.track_hash, wpt.artist, wpt.title, wpt.first_seen, wpt.removed_at, j.status as job_status
FROM watched_playlist_tracks wpt
LEFT JOIN jobs j ON wpt.job_id = j.id
WHERE wpt.playlist_id = ?
AND wpt.downloaded_at IS NULL
{_departed_frag}
AND (j.status IS NULL OR j.status IN ('failed', 'completed_with_errors'))""",
(playlist_id,)
).fetchall()
return {
"playlist_name": playlist["name"],
"missing": [dict(r) for r in rows],
"count": len(rows),
}
@app.get("/api/watched-playlists/{playlist_id}/tracks")
def get_watched_playlist_tracks(playlist_id: str, http_request: Request):
"""Return all tracks for a watched playlist with their download status.
Each track includes its current job status so the UI can show downloaded,
failed, pending, and departed tracks in one place. Every row is returned,
but the summary counts describe current membership only, so they agree with
the figures on the playlist card instead of quietly contradicting them.
"""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
playlist = conn.execute(
f"SELECT name, sync_mode FROM watched_playlists WHERE id = ? AND {_scope_frag}",
(playlist_id, *_scope_params)
).fetchall()
playlist = playlist[0] if playlist else None
if not playlist:
raise HTTPException(status_code=404, detail="Watched playlist not found")
rows = conn.execute(
"""SELECT wpt.track_hash, wpt.artist, wpt.title, wpt.downloaded_at, wpt.job_id,
wpt.first_seen, wpt.removed_at,
j.status as job_status, j.error as job_error
FROM watched_playlist_tracks wpt
LEFT JOIN jobs j ON wpt.job_id = j.id
WHERE wpt.playlist_id = ?
ORDER BY wpt.first_seen""",
(playlist_id,)
).fetchall()
tracks = [dict(r) for r in rows]
current = [t for t in tracks if not t["removed_at"]]
downloaded = sum(1 for t in current if t["downloaded_at"])
failed = sum(1 for t in current if not t["downloaded_at"] and t["job_status"] in ("failed", "completed_with_errors", None))
pending = sum(1 for t in current if not t["downloaded_at"] and t["job_status"] in ("queued", "downloading"))
return {
"playlist_name": playlist["name"],
"sync_mode": playlist["sync_mode"] or "append",
"tracks": tracks,
"total": len(current),
"downloaded": downloaded,
"failed": failed,
"pending": pending,
"departed": len(tracks) - len(current),
}
@app.delete("/api/watched-playlists/{playlist_id}/tracks/{track_hash}/file")
def delete_watched_playlist_track_file(
playlist_id: str,
track_hash: str,
http_request: Request,
):
"""Trash a watched track by its stable playlist identity.
Queue rows are disposable history. A playlist track is not, so Replace must
not depend on the job which happened to download its current file.
"""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
track = conn.execute(
f"""SELECT wpt.artist, wpt.title, wpt.resolved_path, wpt.job_id,
wp.id, wp.name, wp.make_m3u, wp.use_playlists_dir,
wp.sync_mode, wp.custom_subdir, wp.user_id
FROM watched_playlist_tracks wpt
JOIN watched_playlists wp ON wp.id = wpt.playlist_id
WHERE wpt.playlist_id = ? AND wpt.track_hash = ?
AND {_scope_frag}""",
(playlist_id, track_hash, *_scope_params),
).fetchone()
if not track:
raise HTTPException(status_code=404, detail="Watched playlist track not found")
artist = track["artist"] or ""
title = track["title"] or ""
if not artist or not title:
raise HTTPException(status_code=400, detail="Track has no artist/title metadata")
from utils import check_duplicate, move_to_trash
existing = authoritative_audio_file(track["resolved_path"], user_id)
if existing is None:
existing = check_duplicate(artist, title, user_id=user_id)
trashed_files = []
if existing:
trash_result = move_to_trash(existing, user_id=user_id)
if trash_result:
trashed_files.append(existing.name)
lrc_trash = trash_result.with_suffix(".lrc")
if lrc_trash.exists():
trashed_files.append(lrc_trash.name)
else:
try:
existing.unlink()
trashed_files.append(existing.name)
lrc_file = existing.with_suffix(".lrc")
if lrc_file.exists():
lrc_file.unlink()
trashed_files.append(lrc_file.name)
track_dir = existing.parent
if track_dir.exists() and not any(track_dir.iterdir()):
track_dir.rmdir()
except OSError as e:
raise HTTPException(status_code=500, detail=f"Failed to delete: {e}")
with db_conn() as conn:
if track["job_id"]:
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
conn.execute(
f"UPDATE jobs SET file_deleted = 1 WHERE id = ? AND {_scope_frag}",
(track["job_id"], *_scope_params),
)
conn.execute(
"""UPDATE watched_playlist_tracks
SET downloaded_at = NULL, resolved_path = NULL, job_id = NULL
WHERE playlist_id = ? AND track_hash = ?""",
(playlist_id, track_hash),
)
conn.commit()
if track["make_m3u"]:
rebuild_watched_playlist_m3u(
track["id"], track["name"],
use_playlists_dir=bool(track["use_playlists_dir"]),
sync_mode=track["sync_mode"] or "append",
user_id=track["user_id"],
custom_subdir=track["custom_subdir"] or None,
)
return {
"deleted": trashed_files,
"playlist_id": playlist_id,
"track_hash": track_hash,
"trashed": bool(trashed_files),
}
@app.post("/api/watched-playlists/{playlist_id}/tracks/{track_hash}/match")
def match_watched_track_to_library_file(
playlist_id: str,
track_hash: str,
body: LibraryMatchRequest,
http_request: Request,
):
"""Point a missing watched track at a file already in the library.
For when the automatic match is wrong or absent and you know perfectly well
you own the thing. The file must be a real audio file inside your music
folder; whether it is the right song is your call, which is the point. It is
recorded as the track's resolved path exactly as a download would be, so the
M3U rebuild picks it up and the next refresh leaves it alone.
"""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
track = conn.execute(
f"""SELECT wp.id, wp.name, wp.make_m3u, wp.use_playlists_dir,
wp.sync_mode, wp.custom_subdir, wp.user_id
FROM watched_playlist_tracks wpt
JOIN watched_playlists wp ON wp.id = wpt.playlist_id
WHERE wpt.playlist_id = ? AND wpt.track_hash = ?
AND {_scope_frag}""",
(playlist_id, track_hash, *_scope_params),
).fetchone()
if not track:
raise HTTPException(status_code=404, detail="Watched playlist track not found")
audio = authoritative_audio_file(body.path, track["user_id"])
if audio is None:
raise HTTPException(status_code=400, detail="That is not an audio file in your music library")
with db_conn() as conn:
conn.execute(
"""UPDATE watched_playlist_tracks
SET downloaded_at = datetime('now'), resolved_path = ?
WHERE playlist_id = ? AND track_hash = ?""",
(str(audio), playlist_id, track_hash),
)
conn.commit()
if track["make_m3u"]:
rebuild_watched_playlist_m3u(
track["id"], track["name"],
use_playlists_dir=bool(track["use_playlists_dir"]),
sync_mode=track["sync_mode"] or "append",
user_id=track["user_id"],
custom_subdir=track["custom_subdir"] or None,
)
return {"playlist_id": playlist_id, "track_hash": track_hash, "path": str(audio)}
@app.post("/api/watched-playlists/{playlist_id}/retry-track")
def retry_missing_track(playlist_id: str, request: RetryMissingTrackRequest, http_request: Request):
"""Manually retry a single missing track for a watched playlist.
Searches all sources for the best match and queues a download, routing the
result back into the watched playlist's folder and M3U (if enabled).
"""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
playlist = conn.execute(
f"SELECT id, name, convert_audio, use_playlists_dir, preferred_sources, priority_source, custom_subdir FROM watched_playlists WHERE id = ? AND {_scope_frag}",
(playlist_id, *_scope_params)
).fetchone()
if not playlist:
raise HTTPException(status_code=404, detail="Watched playlist not found")
custom_subdir = (playlist["custom_subdir"] or "").strip() or None
# Kick off a single-track bulk import linked to this watched playlist so the
# M3U rebuild and watched_playlist_tracks update happen automatically on completion.
import_id = start_bulk_import_for_tracks(
tracks=[(request.artist, request.title)],
convert_audio=bool(playlist["convert_audio"]),
watch_playlist_id=playlist_id,
use_playlists_dir=bool(playlist["use_playlists_dir"]),
user_id=user_id,
preferred_sources=playlist["preferred_sources"] or "all",
priority_source=playlist["priority_source"],
custom_subdir=custom_subdir,
)
return {"import_id": import_id, "status": "queued", "message": f"Searching for {request.artist} - {request.title}"}
@app.get("/api/watched-playlists/{playlist_id}/track-candidates")
def get_watched_playlist_track_candidates(
playlist_id: str,
artist: str,
title: str,
http_request: Request,
limit: int = 4,
):
"""Return top manual-pick candidates for a missing watched-playlist track."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
artist = (artist or "").strip()
title = (title or "").strip()
if not artist or not title:
raise HTTPException(status_code=400, detail="artist and title are required")
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
playlist = conn.execute(
f"""SELECT id, preferred_sources
FROM watched_playlists
WHERE id = ? AND {_scope_frag}""",
(playlist_id, *_scope_params)
).fetchone()
if not playlist:
raise HTTPException(status_code=404, detail="Watched playlist not found")
preferred_sources = playlist["preferred_sources"] or "all"
sources = None if preferred_sources == "all" else [s.strip() for s in preferred_sources.split(",") if s.strip()]
query = f"{artist} - {title}".strip(" -")
fetch_limit = max(1, min(limit, 10))
# Someone is waiting on this list in the UI, so queue briefly rather than
# making them stare at a spinner while a bulk import finishes its turn.
results, _ = search_all(query, limit=fetch_limit, sources=sources, include_soulseek=True,
slot_wait=SEARCH_SLOT_WAIT_INTERACTIVE)
return {
"query": query,
"results": results[:fetch_limit],
}
@app.post("/api/watched-playlists/{playlist_id}/queue-track-candidate")
def queue_watched_playlist_track_candidate(
playlist_id: str,
request: QueueMissingTrackCandidateRequest,
http_request: Request,
):
"""Queue a specific candidate for a missing watched-playlist track."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
source = (request.source or "youtube").strip().lower()
artist = (request.artist or "").strip()
title = (request.title or "").strip()
if not artist or not title:
raise HTTPException(status_code=400, detail="artist and title are required")
if source == "youtube":
if not request.video_id or not is_valid_youtube_id(request.video_id):
raise HTTPException(status_code=400, detail="Invalid YouTube video ID")
elif source in URL_BASED_SOURCES:
if not request.source_url:
raise HTTPException(status_code=400, detail=f"{source.capitalize()} candidate requires source_url")
try:
validate_source_url(source, request.source_url)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
elif source == "soulseek":
if not (request.slskd_username and request.slskd_filename):
raise HTTPException(status_code=400, detail="Soulseek candidate requires username and filename")
else:
raise HTTPException(status_code=400, detail=f"Unsupported source: {source}")
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
playlist = conn.execute(
f"""SELECT id, name, convert_audio, use_playlists_dir, custom_subdir,
preferred_sources, priority_source
FROM watched_playlists
WHERE id = ? AND {_scope_frag}""",
(playlist_id, *_scope_params)
).fetchone()
if not playlist:
raise HTTPException(status_code=404, detail="Watched playlist not found")
track_hash = hash_track(artist, title)
track = conn.execute(
"SELECT playlist_id FROM watched_playlist_tracks WHERE playlist_id = ? AND track_hash = ?",
(playlist_id, track_hash),
).fetchone()
if not track:
raise HTTPException(status_code=404, detail="Watched playlist track not found")
job_id = str(uuid.uuid4())[:8]
if source == "soulseek":
source_url = f"soulseek://{request.slskd_username}/{request.slskd_filename}"
elif source in URL_BASED_SOURCES:
source_url = request.source_url
else:
source_url = f"https://www.youtube.com/watch?v={request.video_id}"
target_id = ensure_acquisition_target(
owner_type="watched_playlist",
owner_key=f"{playlist_id}:{track_hash}",
mode="automatic",
artist=artist,
title=title,
user_id=user_id,
allowed_sources=playlist["preferred_sources"] or "all",
priority_source=playlist["priority_source"],
convert_audio=bool(playlist["convert_audio"]),
destination={
"watch_playlist_id": playlist_id,
"use_playlists_dir": bool(playlist["use_playlists_dir"]),
"custom_subdir": playlist["custom_subdir"],
},
conn=conn,
)
conn.execute(
"""INSERT INTO jobs
(id, video_id, title, artist, status, download_type, source, slskd_username, slskd_filename,
slskd_size, convert_audio, source_url, user_id, acquisition_target_id)
VALUES (?, ?, ?, ?, 'queued', 'single', ?, ?, ?, ?, ?, ?, ?, ?)""",
(
job_id,
request.video_id,
title,
artist,
source,
request.slskd_username,
request.slskd_filename,
request.slskd_size,
int(bool(playlist["convert_audio"])),
source_url,
user_id,
target_id,
),
)
conn.execute(
"UPDATE watched_playlist_tracks SET job_id = ?, downloaded_at = NULL WHERE playlist_id = ? AND track_hash = ?",
(job_id, playlist_id, track_hash),
)
conn.commit()
acquisition_cycle = begin_acquisition_cycle(target_id)
attach_job_to_acquisition(job_id, target_id, acquisition_cycle)
convert_audio = bool(playlist["convert_audio"])
custom_subdir = (playlist["custom_subdir"] or "").strip() or None
playlist_name = playlist["name"] if (playlist["use_playlists_dir"] or custom_subdir) else None
use_playlists_dir = bool(playlist["use_playlists_dir"])
selected_candidate = {
"video_id": request.video_id,
"title": title,
"artist": artist,
"channel": artist,
"source": source,
"source_url": source_url,
"slskd_username": request.slskd_username,
"slskd_filename": request.slskd_filename,
"slskd_size": request.slskd_size,
}
spawn_daemon_thread(
process_acquisition_cycle,
job_id,
selected_candidate,
artist,
title,
convert_audio,
target_id=target_id,
cycle=acquisition_cycle,
automatic=False,
allowed_sources={source},
priority_source=source,
playlist_name=playlist_name,
use_playlists_dir=use_playlists_dir,
user_id=user_id,
custom_subdir=custom_subdir,
)
return {"job_id": job_id, "status": "queued", "message": f"Queued {artist} - {title}"}
@app.post("/api/watched-playlists/{playlist_id}/refresh")
def refresh_single_playlist(playlist_id: str, http_request: Request):
"""Force an immediate refresh of a specific watched playlist"""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
playlist = conn.execute(
f"SELECT * FROM watched_playlists WHERE id = ? AND {_scope_frag}",
(playlist_id, *_scope_params)
).fetchone()
if not playlist:
raise HTTPException(status_code=404, detail="Watched playlist not found")
result = refresh_watched_playlist(playlist_id)
return result
@app.post("/api/watched-playlists/check-all")
def check_all_watched_playlists(http_request: Request):
"""Check all playlists due for refresh (called by cron)"""
if not http_request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
with db_conn() as conn:
conn.row_factory = sqlite3.Row
playlists = conn.execute("""
SELECT id, name FROM watched_playlists
WHERE enabled = 1
AND (last_checked IS NULL
OR datetime(last_checked, '+' || refresh_interval_hours || ' hours') < datetime('now'))
""").fetchall()
if not playlists:
return {"checked": 0, "message": "No playlists due for refresh", "results": []}
results = []
for playlist in playlists:
result = refresh_watched_playlist(playlist["id"])
results.append(result)
total_new = sum(r.get("new_tracks", 0) for r in results)
total_queued = sum(r.get("queued", 0) for r in results)
return {
"checked": len(results),
"total_new_tracks": total_new,
"total_queued": total_queued,
"results": results
}
@app.post("/api/watched-playlists/tag-all-playlists-comment")
def tag_all_playlists_comment(http_request: Request):
"""One-shot backfill: stamp playlist name(s) into COMMENT for every downloaded
watched track, so an existing library catches up with the macOS Music feature.
New downloads are tagged automatically at completion; this is for what came before.
"""
if not http_request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
if not get_setting_bool("playlist_comment_tagging", False):
raise HTTPException(status_code=400, detail="Enable 'Tag playlist names in COMMENT' in Settings first")
from metadata import set_playlist_comment
from downloads import _all_playlist_names_for_track
with db_conn() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""SELECT DISTINCT artist, title, resolved_path
FROM watched_playlist_tracks
WHERE downloaded_at IS NOT NULL AND resolved_path IS NOT NULL"""
).fetchall()
tagged = skipped = errors = 0
for row in rows:
audio_file = Path(row["resolved_path"])
if not audio_file.exists():
skipped += 1
continue
try:
with db_conn() as conn2:
conn2.row_factory = sqlite3.Row
names = _all_playlist_names_for_track(conn2, row["artist"], row["title"])
if names and set_playlist_comment(audio_file, names):
tagged += 1
else:
skipped += 1
except Exception as e:
print(f"Tag-all COMMENT failed for {audio_file}: {e}")
errors += 1
return {"tagged": tagged, "skipped": skipped, "errors": errors}
# ---------------------------------------------------------------------------
# Watched Artists
# ---------------------------------------------------------------------------
@app.get("/api/watched-artists/search")
def search_watched_artist(q: str):
"""Search MusicBrainz for an artist by name. Returns up to 5 candidates."""
if not q or not q.strip():
return {"results": []}
try:
results = search_artist_mbid(q.strip())
except MusicBrainzUnavailable as exc:
raise HTTPException(status_code=503, detail=f"MusicBrainz unreachable: {exc}. Try again in a moment.")
return {"results": results}
@app.post("/api/watched-artists")
def add_watched_artist(body: WatchedArtistRequest, http_request: Request):
"""Add an artist to watch. Seeds all known singles then queues any after from_date."""
_enforce_peon_format(http_request, body)
user_id = http_request.state.user_id
# Singles and albums are independent follows, so arriving here for an artist
# you already watch is no longer an error: it means "and albums too, please".
# Switch on whichever mode was asked for and leave the other one exactly as it
# was; nobody wants to lose their singles history by browsing an album list.
with db_conn() as conn:
existing = conn.execute(
"SELECT id FROM watched_artists WHERE mbid = ? AND (user_id = ? OR (user_id IS NULL AND ? IS NULL))",
(body.mbid, user_id, user_id)
).fetchone()
if existing:
artist_id = existing[0]
with db_conn() as conn:
if body.watch_singles:
# Re-following for singles re-applies the from_date the user just
# picked; that is the whole point of the form they filled in.
conn.execute(
"UPDATE watched_artists SET watch_singles = 1, from_date = ?, enabled = 1 WHERE id = ?",
(body.from_date, artist_id)
)
conn.commit()
already_watched = True
else:
artist_id = str(uuid.uuid4())[:8]
already_watched = False
with db_conn() as conn:
conn.execute(
"""INSERT INTO watched_artists
(id, name, mbid, from_date, refresh_interval_hours, convert_audio,
watch_singles, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(artist_id, body.name, body.mbid, body.from_date,
body.refresh_interval_hours, int(body.convert_audio),
int(body.watch_singles), user_id)
)
conn.commit()
# If auto_add_albums was requested, seed the current album list as
# already-seen BEFORE flipping the flag on, so the first refresh (spawned
# below) can never mistake the whole back catalogue for new arrivals. If
# MusicBrainz is unreachable right now, the artist is still created and
# watched for singles as normal; auto_add_albums simply stays off and the
# warning tells the user to flip the toggle again once MB is back.
auto_add_albums_warning = None
already_auto_adding = False
if already_watched:
with db_conn() as conn:
row = conn.execute(
"SELECT auto_add_albums FROM watched_artists WHERE id = ?", (artist_id,)
).fetchone()
already_auto_adding = bool(row and row[0])
if body.auto_add_albums and not already_auto_adding:
try:
seed_artist_albums(artist_id, body.mbid, user_id=user_id)
with db_conn() as conn:
conn.execute("UPDATE watched_artists SET auto_add_albums = 1 WHERE id = ?", (artist_id,))
conn.commit()
except MusicBrainzUnavailable as e:
auto_add_albums_warning = (
f"Could not seed the current album list ({e}); auto-add-albums was left off. "
"Try the toggle again once MusicBrainz is reachable."
)
# The first refresh seeds the entire back-catalogue from MusicBrainz, which
# for a prolific artist (Radiohead, looking at you) can take a couple of
# minutes. Run it in the background so the request returns immediately; the UI
# already polls refresh_state/refresh_stage to show progress. Running it inline
# would leave the HTTP request hanging long enough to trip client/proxy timeouts.
spawn_daemon_thread(refresh_watched_artist, artist_id)
response = {
"id": artist_id,
"name": body.name,
"mbid": body.mbid,
"from_date": body.from_date,
"auto_add_albums": (bool(body.auto_add_albums) or already_auto_adding) and auto_add_albums_warning is None,
"watch_singles": bool(body.watch_singles),
"already_watched": already_watched,
"refresh_state": "running",
"seeding": True,
}
if auto_add_albums_warning:
response["warning"] = auto_add_albums_warning
return response
@app.get("/api/watched-artists")
def list_watched_artists(http_request: Request):
"""List all watched artists with track counts."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
rows = conn.execute(
f"""SELECT wa.*,
(SELECT COUNT(*) FROM watched_artist_tracks wat WHERE wat.artist_id = wa.id) as tracked_count,
(SELECT COUNT(*) FROM watched_artist_tracks wat WHERE wat.artist_id = wa.id AND wat.downloaded_at IS NOT NULL) as downloaded_count
FROM watched_artists wa
WHERE {_scope_frag}
ORDER BY wa.name COLLATE NOCASE""",
_scope_params
).fetchall()
return {"artists": [_expose_conversion_intent(dict(r)) for r in rows]}
@app.get("/api/upgrades/candidates")
def list_upgrade_candidates(http_request: Request, page: int = 1, per_page: int = 10):
"""Paginated list of library files sitting below target quality (Watched Upgrades).
Read-only: each row carries any cached search result, but searching is triggered
per-row by the client (see /search below). Hidden from peons.
"""
if _is_peon(http_request):
raise HTTPException(status_code=403, detail="Not available for this account")
if not get_setting_bool("enable_track_upgrades", False):
return {"enabled": False, "items": [], "total": 0, "page": 1, "pages": 0}
data = get_candidates_page(http_request.state.user_id, page=page, per_page=per_page)
data["enabled"] = True
return data
@app.post("/api/upgrades/candidates/{candidate_id}/search")
def search_upgrade_candidate(candidate_id: int, http_request: Request, force: bool = False):
"""Search for a better copy of one candidate (network). Client calls this per row, ~1/s."""
if _is_peon(http_request):
raise HTTPException(status_code=403, detail="Not available for this account")
if not get_setting_bool("enable_track_upgrades", False):
raise HTTPException(status_code=400, detail="Track upgrades are disabled")
result = search_candidate(http_request.state.user_id, candidate_id, force=force)
if result is None:
raise HTTPException(status_code=404, detail="Candidate not found")
return result
@app.post("/api/upgrades/candidates/{candidate_id}/dismiss")
def dismiss_upgrade_candidate(candidate_id: int, http_request: Request):
"""Stop suggesting an upgrade for this file (until the file itself changes)."""
if _is_peon(http_request):
raise HTTPException(status_code=403, detail="Not available for this account")
if not dismiss_candidate(http_request.state.user_id, candidate_id):
raise HTTPException(status_code=404, detail="Candidate not found")
return {"status": "ok"}
@app.post("/api/upgrades/candidates/{candidate_id}/upgrade")
def upgrade_candidate(candidate_id: int, http_request: Request, force: bool = False):
"""Download the proposed copy, verify it, and swap it in if it passes every check.
Synchronous: it downloads and fingerprints, so it can take a little while. The old
file goes to quarantine (manual purge), never deleted. Hidden from peons.
force=true skips the same-recording gates (still quarantines the old file).
"""
if _is_peon(http_request):
raise HTTPException(status_code=403, detail="Not available for this account")
if not get_setting_bool("enable_track_upgrades", False):
raise HTTPException(status_code=400, detail="Track upgrades are disabled")
result = perform_upgrade(http_request.state.user_id, candidate_id, force=force)
if result.get("status") == "error" and result.get("reason") == "Candidate not found":
raise HTTPException(status_code=404, detail="Candidate not found")
return result
@app.post("/api/upgrades/upgrade-all")
def upgrade_all(http_request: Request):
"""Best-effort upgrade of every below-target candidate that has a proposal.
Runs in the background (downloads take time); the UI refreshes afterwards.
"""
if _is_peon(http_request):
raise HTTPException(status_code=403, detail="Not available for this account")
if not get_setting_bool("enable_track_upgrades", False):
raise HTTPException(status_code=400, detail="Track upgrades are disabled")
user_id = http_request.state.user_id
# Mark it live here, not in the thread, so the UI's first poll can't slip
# into the gap and conclude the run already finished.
mark_upgrade_all_starting(user_id)
spawn_daemon_thread(perform_upgrade_all, user_id)
return {"status": "started"}
@app.get("/api/upgrades/upgrade-all/status")
def upgrade_all_status(http_request: Request):
"""Progress of this user's background Upgrade All run, so the UI can poll it
and refresh the list itself instead of asking the user to hit reload."""
if _is_peon(http_request):
raise HTTPException(status_code=403, detail="Not available for this account")
return get_upgrade_all_progress(http_request.state.user_id)
@app.post("/api/upgrades/rescan")
def rescan_upgrades(http_request: Request):
"""Kick off an immediate library scan (admin only). Cheap and network-free."""
if not http_request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin only")
if not get_setting_bool("enable_track_upgrades", False):
raise HTTPException(status_code=400, detail="Track upgrades are disabled")
return {"status": "ok", "totals": run_scan_all()}
@app.get("/api/upgrades/quarantine")
def get_quarantine(http_request: Request):
"""List files a Track Upgrade swap displaced. Admins also see files sitting
in the quarantine folder with no tracking row (predates this feature, or
dropped in by hand); those are delete-only since their original library
location was never recorded.
"""
if _is_peon(http_request):
raise HTTPException(status_code=403, detail="Not available for this account")
items = list_quarantine(http_request.state.user_id, is_admin=http_request.state.is_admin)
return {"items": items}
@app.delete("/api/upgrades/quarantine/orphaned")
def delete_orphaned_quarantine_entry(filename: str, http_request: Request):
"""Delete a quarantine-folder file with no tracking row (admin only).
Registered before the /{entry_id} routes below: Starlette matches path
routes in registration order, and "orphaned" is a syntactically valid
(if not a numeric) value for {entry_id}, so this has to come first or it's
unreachable, shadowed by the generic route.
"""
if not http_request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin only")
result = delete_orphaned_quarantine_file(filename)
if result.get("status") == "error":
raise HTTPException(status_code=404, detail=result.get("reason") or "File not found")
return result
@app.post("/api/upgrades/quarantine/{entry_id}/restore")
def restore_quarantine_entry(entry_id: int, http_request: Request):
"""Undo one upgrade: put the quarantined file back where it lived.
Whatever the upgrade put there instead is itself quarantined rather than
deleted, so this can be undone too if it turns out to be the wrong call.
"""
if _is_peon(http_request):
raise HTTPException(status_code=403, detail="Not available for this account")
result = restore_from_quarantine(
http_request.state.user_id, entry_id, is_admin=http_request.state.is_admin
)
if result.get("status") == "error" and result.get("reason") == "Quarantine entry not found":
raise HTTPException(status_code=404, detail="Quarantine entry not found")
return result
@app.delete("/api/upgrades/quarantine/{entry_id}")
def delete_quarantine_entry_route(entry_id: int, http_request: Request):
"""Permanently discard a quarantined file. No undo past this point."""
if _is_peon(http_request):
raise HTTPException(status_code=403, detail="Not available for this account")
result = delete_quarantine_entry(
http_request.state.user_id, entry_id, is_admin=http_request.state.is_admin
)
if result.get("status") == "error" and result.get("reason") == "Quarantine entry not found":
raise HTTPException(status_code=404, detail="Quarantine entry not found")
return result
def _audio_audit_filters(
classification: str = "",
container: str = "",
codec: str = "",
source: str = "",
effective_quality: str = "",
lossless_only: str = "",
) -> dict:
if lossless_only not in ("", "stored", "recorded"):
raise HTTPException(
status_code=400,
detail="lossless_only must be 'stored' or 'recorded'",
)
return {
"classification": classification,
"container": container,
"codec": codec,
"source": source,
"effective_quality": effective_quality,
"lossless_only": lossless_only,
}
@app.get("/api/audio-audit")
def audio_audit_status(http_request: Request):
"""Current trusted snapshot plus any in-progress read-only scan."""
if _is_peon(http_request):
raise HTTPException(status_code=403, detail="Not available for this account")
return get_audit_status(http_request.state.user_id)
@app.post("/api/audio-audit/scans")
def start_audio_audit(http_request: Request):
"""Start a full-library provenance scan without changing any audio files."""
if _is_peon(http_request):
raise HTTPException(status_code=403, detail="Not available for this account")
return start_audit_scan(http_request.state.user_id)
@app.get("/api/audio-audit/files")
def audio_audit_files(
http_request: Request,
page: int = 1,
per_page: int = 25,
classification: str = "",
container: str = "",
codec: str = "",
source: str = "",
effective_quality: str = "",
lossless_only: str = "",
):
"""Paginated evidence rows from the last complete audit snapshot."""
if _is_peon(http_request):
raise HTTPException(status_code=403, detail="Not available for this account")
filters = _audio_audit_filters(
classification,
container,
codec,
source,
effective_quality,
lossless_only,
)
return get_audit_files(
http_request.state.user_id,
filters=filters,
page=page,
per_page=per_page,
)
@app.get("/api/audio-audit/export")
def export_audio_audit(
http_request: Request,
format: str = "csv",
classification: str = "",
container: str = "",
codec: str = "",
source: str = "",
effective_quality: str = "",
lossless_only: str = "",
):
"""Download the filtered snapshot as an explicitly dry-run CSV or JSON report."""
if _is_peon(http_request):
raise HTTPException(status_code=403, detail="Not available for this account")
export_format = format.strip().lower()
if export_format not in ("csv", "json"):
raise HTTPException(status_code=400, detail="format must be 'csv' or 'json'")
filters = _audio_audit_filters(
classification,
container,
codec,
source,
effective_quality,
lossless_only,
)
report = get_audit_export(http_request.state.user_id, filters)
scan = report["metadata"].get("scan") or {}
scan_id = scan.get("id") or "no-snapshot"
headers = {
"Content-Disposition": (
f'attachment; filename="musicgrabber-audio-audit-{scan_id}.{export_format}"'
)
}
if export_format == "json":
body = json.dumps(report, ensure_ascii=False, indent=2, default=str)
return StreamingResponse(
iter([body]),
media_type="application/json; charset=utf-8",
headers=headers,
)
output = io.StringIO()
fieldnames = [
"criteria_version",
"read_only_notice",
"scan_id",
"scan_completed_at",
"path",
"artist",
"title",
"classification",
"classification_label",
"stored_quality",
"effective_quality",
"container",
"codec",
"bitrate_kbps",
"sample_rate_hz",
"bits_per_sample",
"source",
"source_quality",
"source_codec",
"source_bitrate_kbps",
"evidence",
"caveats",
"read_error",
]
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
for item in report["files"]:
writer.writerow({
"criteria_version": report["metadata"]["criteria_version"],
"read_only_notice": report["metadata"]["read_only_notice"],
"scan_id": scan_id,
"scan_completed_at": scan.get("completed_at"),
"path": item.get("path"),
"artist": item.get("artist"),
"title": item.get("title"),
"classification": item.get("classification"),
"classification_label": item.get("classification_label"),
"stored_quality": item.get("stored_quality"),
"effective_quality": item.get("effective_quality"),
"container": item.get("container"),
"codec": item.get("codec"),
"bitrate_kbps": item.get("bitrate_kbps"),
"sample_rate_hz": item.get("sample_rate_hz"),
"bits_per_sample": item.get("bits_per_sample"),
"source": item.get("source"),
"source_quality": item.get("source_quality"),
"source_codec": item.get("source_codec"),
"source_bitrate_kbps": item.get("source_bitrate_kbps"),
"evidence": json.dumps(item.get("evidence") or [], ensure_ascii=False),
"caveats": json.dumps(item.get("caveats") or [], ensure_ascii=False),
"read_error": item.get("read_error"),
})
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv; charset=utf-8",
headers=headers,
)
@app.put("/api/watched-artists/{artist_id}")
def update_watched_artist(artist_id: str, request: WatchedArtistUpdate, http_request: Request):
"""Update a watched artist's settings."""
if _is_peon(http_request):
request.convert_audio = None # Peons cannot change conversion setting
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
updates: list[str] = []
params: list = []
if request.enabled is not None:
updates.append("enabled = ?"); params.append(int(request.enabled))
if request.refresh_interval_hours is not None:
updates.append("refresh_interval_hours = ?"); params.append(request.refresh_interval_hours)
if request.convert_audio is not None:
updates.append("convert_audio = ?"); params.append(int(request.convert_audio))
if request.from_date is not None:
updates.append("from_date = ?"); params.append(request.from_date)
# auto_add_albums: switching it ON seeds the artist's current albums as
# already-seen BEFORE the flag itself is persisted below, so nothing reading
# this flag (the scheduler included) can ever observe "on" without the seed
# rows already in place. That is the anti-avalanche guarantee; flipping it
# off, or leaving an already-on artist on, needs no reseed.
if request.auto_add_albums is not None:
with db_conn() as conn:
row = conn.execute(
f"SELECT auto_add_albums, mbid FROM watched_artists WHERE id = ? AND {_scope_frag}",
(artist_id, *_scope_params)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Artist not found")
if request.auto_add_albums and not row[0]:
try:
seed_artist_albums(artist_id, row[1], user_id=user_id)
except MusicBrainzUnavailable as e:
raise HTTPException(
status_code=503,
detail=f"Could not seed the current album list: {e}. Try again in a moment.",
)
updates.append("auto_add_albums = ?"); params.append(int(request.auto_add_albums))
if request.watch_singles is not None:
updates.append("watch_singles = ?"); params.append(int(request.watch_singles))
# Both switches off leaves a follow that follows nothing, which would sit in
# the list quietly doing sod all forever. Refuse it and point at Delete, which
# is what the user actually meant.
if request.watch_singles is not None or request.auto_add_albums is not None:
with db_conn() as conn:
current = conn.execute(
f"SELECT watch_singles, auto_add_albums FROM watched_artists WHERE id = ? AND {_scope_frag}",
(artist_id, *_scope_params)
).fetchone()
if not current:
raise HTTPException(status_code=404, detail="Artist not found")
next_singles = request.watch_singles if request.watch_singles is not None else bool(current[0])
next_albums = request.auto_add_albums if request.auto_add_albums is not None else bool(current[1])
if not next_singles and not next_albums:
raise HTTPException(
status_code=400,
detail="An artist has to be followed for singles, albums or both. "
"To stop following them entirely, use Delete.",
)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
with db_conn() as conn:
conn.execute(
f"UPDATE watched_artists SET {', '.join(updates)} WHERE id = ? AND {_scope_frag}",
(*params, artist_id, *_scope_params)
)
conn.commit()
conn.row_factory = sqlite3.Row
updated = conn.execute(
f"SELECT * FROM watched_artists WHERE id = ? AND {_scope_frag}",
(artist_id, *_scope_params)
).fetchone()
if not updated:
raise HTTPException(status_code=404, detail="Artist not found")
wake_artist_scheduler()
return dict(updated)
@app.delete("/api/watched-artists/{artist_id}")
def delete_watched_artist(artist_id: str, http_request: Request):
"""Remove a watched artist and all its tracked tracks."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
artist = conn.execute(
f"SELECT name FROM watched_artists WHERE id = ? AND {_scope_frag}",
(artist_id, *_scope_params)
).fetchone()
if not artist:
raise HTTPException(status_code=404, detail="Artist not found")
conn.execute("DELETE FROM watched_artist_tracks WHERE artist_id = ?", (artist_id,))
conn.execute("DELETE FROM watched_artists WHERE id = ?", (artist_id,))
conn.commit()
wake_artist_scheduler()
return {"success": True, "message": f"Stopped watching {artist[0]}"}
@app.post("/api/watched-artists/{artist_id}/refresh")
def refresh_single_artist(artist_id: str, http_request: Request):
"""Manually trigger a refresh for one watched artist."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
row = conn.execute(
f"SELECT id FROM watched_artists WHERE id = ? AND {_scope_frag}",
(artist_id, *_scope_params)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Artist not found")
# Background, same as create: a manual refresh of a prolific artist would
# otherwise hang the request for minutes. The UI polls refresh_state.
spawn_daemon_thread(refresh_watched_artist, artist_id)
return {"artist_id": artist_id, "refresh_state": "running", "started": True}
@app.get("/api/watched-artists/{artist_id}/tracks")
def get_watched_artist_tracks(artist_id: str, http_request: Request, limit: int = 50, offset: int = 0):
"""Return a page of tracked singles for a watched artist with job status.
Prolific artists run to hundreds of singles, so the list is paginated for
display ('cycles'). limit<=0 means "everything" for callers that still want
the lot. Always returns the total so the UI can render a pager.
"""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
# Clamp to sane bounds; limit<=0 is the "give me all of it" escape hatch.
paged = limit > 0
limit = min(limit, 500) if paged else -1
offset = max(offset, 0)
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
artist = conn.execute(
f"SELECT name FROM watched_artists WHERE id = ? AND {_scope_frag}",
(artist_id, *_scope_params)
).fetchone()
if not artist:
raise HTTPException(status_code=404, detail="Artist not found")
total = conn.execute(
"SELECT COUNT(*) FROM watched_artist_tracks WHERE artist_id = ?",
(artist_id,)
).fetchone()[0]
query = """SELECT wat.artist, wat.title, wat.release_date, wat.downloaded_at,
wat.resolved_path, wat.job_id, j.status as job_status, j.error as job_error
FROM watched_artist_tracks wat
LEFT JOIN jobs j ON wat.job_id = j.id
WHERE wat.artist_id = ?
ORDER BY wat.release_date DESC NULLS LAST, wat.title"""
params: tuple = (artist_id,)
if paged:
query += " LIMIT ? OFFSET ?"
params = (artist_id, limit, offset)
tracks = conn.execute(query, params).fetchall()
return {
"artist": artist[0],
"tracks": [dict(t) for t in tracks],
"total": total,
"limit": limit,
"offset": offset,
}
@app.get("/api/watched-artists/{artist_id}/missing")
def get_missing_artist_tracks(artist_id: str, http_request: Request):
"""Return singles that haven't been downloaded and have no active job."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
artist = conn.execute(
f"SELECT name FROM watched_artists WHERE id = ? AND {_scope_frag}",
(artist_id, *_scope_params)
).fetchone()
if not artist:
raise HTTPException(status_code=404, detail="Artist not found")
tracks = conn.execute(
"""SELECT wat.artist, wat.title, wat.release_date, j.status as job_status, j.error as job_error
FROM watched_artist_tracks wat
LEFT JOIN jobs j ON wat.job_id = j.id
WHERE wat.artist_id = ?
AND wat.downloaded_at IS NULL
AND (j.status IS NULL OR j.status NOT IN ('queued', 'downloading'))
ORDER BY wat.release_date DESC NULLS LAST, wat.title""",
(artist_id,)
).fetchall()
return {"artist": artist[0], "tracks": [dict(t) for t in tracks]}
@app.get("/api/watched-artists/{artist_id}/albums")
def get_watched_artist_albums(artist_id: str, http_request: Request):
"""Return known albums with both historical and current display state.
``status`` retains the event that introduced the row (seen/queued/failed),
while ``display_status`` combines the linked import with a cheap on-disk
count. Populated by seeding (on enabling
auto_add_albums) and by refreshes once it's on; empty for artists that have
never had the toggle switched on."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
artist = conn.execute(
f"SELECT name FROM watched_artists WHERE id = ? AND {_scope_frag}",
(artist_id, *_scope_params)
).fetchone()
if not artist:
raise HTTPException(status_code=404, detail="Artist not found")
rows = conn.execute(
"""SELECT waa.title, waa.year, waa.release_mbid,
waa.release_group_mbid, waa.status, waa.queued_at,
waa.import_id, waa.attempt_count, waa.last_attempt_at,
waa.last_error, bi.status AS import_status,
bi.album_total_tracks AS expected_track_count
FROM watched_artist_albums waa
LEFT JOIN bulk_imports bi ON bi.id = waa.import_id
WHERE waa.artist_id = ?
ORDER BY year DESC NULLS LAST, title""",
(artist_id,)
).fetchall()
artist_name = artist["name"]
out = []
for r in rows:
d = dict(r)
# Cheap filesystem count only; no MusicBrainz call per row. The import's
# album_total_tracks is the expected full release size, even when that
# particular import only had to fetch a missing subset.
audio_count = album_audio_file_count(
artist_name, d["title"], user_id=user_id
)
d.update(watched_album_display_state(
d.get("status"),
d.get("import_status"),
audio_count,
d.get("expected_track_count"),
))
if d.get("status") == "failed" and d.get("last_error"):
count = int(d.get("attempt_count") or 0)
d["status_detail"] = (
f"Queue attempt {count} failed: {d['last_error']}. "
"It will retry on the next artist check."
)
out.append(d)
return {"artist": artist_name, "albums": out}
@app.post("/api/watched-artists/{artist_id}/retry-track")
def retry_missing_artist_track(artist_id: str, request: RetryMissingTrackRequest, http_request: Request):
"""Retry downloading a specific missing single."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
artist = conn.execute(
f"SELECT name, convert_audio FROM watched_artists WHERE id = ? AND {_scope_frag}",
(artist_id, *_scope_params)
).fetchone()
if not artist:
raise HTTPException(status_code=404, detail="Artist not found")
import_id = start_bulk_import_for_tracks(
[(request.artist, request.title)],
convert_audio=bool(artist[1]),
watch_artist_id=artist_id,
user_id=user_id,
)
return {"success": True, "import_id": import_id}
@app.post("/api/watched-artists/{artist_id}/retry-all-missing")
def retry_all_missing_artist_tracks(artist_id: str, http_request: Request):
"""Queue all undownloaded singles for this artist in one bulk import."""
user_id = http_request.state.user_id
is_admin = http_request.state.is_admin
with db_conn() as conn:
conn.row_factory = sqlite3.Row
_scope_frag, _scope_params = _user_scope(user_id, is_admin)
artist = conn.execute(
f"SELECT name, convert_audio FROM watched_artists WHERE id = ? AND {_scope_frag}",
(artist_id, *_scope_params)
).fetchone()
if not artist:
raise HTTPException(status_code=404, detail="Artist not found")
tracks = conn.execute(
"""SELECT artist, title FROM watched_artist_tracks
WHERE artist_id = ?
AND downloaded_at IS NULL
AND (job_id IS NULL OR job_id NOT IN (
SELECT id FROM jobs WHERE status IN ('queued', 'downloading')
))
ORDER BY release_date DESC NULLS LAST, title""",
(artist_id,)
).fetchall()
if not tracks:
return {"success": True, "queued": 0, "import_id": None}
import_id = start_bulk_import_for_tracks(
[(t["artist"], t["title"]) for t in tracks],
convert_audio=bool(artist["convert_audio"]),
watch_artist_id=artist_id,
user_id=user_id,
)
return {"success": True, "queued": len(tracks), "import_id": import_id}
@app.post("/api/watched-artists/check-all")
def check_all_watched_artists(http_request: Request):
"""Trigger a refresh for all watched artists that are due."""
if not http_request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
with db_conn() as conn:
conn.row_factory = sqlite3.Row
artists = conn.execute("""
SELECT id, name FROM watched_artists
WHERE enabled = 1
AND (last_checked IS NULL
OR datetime(last_checked, '+' || refresh_interval_hours || ' hours') < datetime('now'))
""").fetchall()
if not artists:
return {"checked": 0, "message": "No artists due for refresh", "results": []}
results = []
for a in artists:
result = refresh_watched_artist(a["id"])
results.append(result)
total_new = sum(r.get("new_tracks", 0) for r in results)
return {
"checked": len(results),
"total_new_tracks": total_new,
"results": results,
}
# =============================================================================
# Album Download Routes
# =============================================================================
@app.get("/api/albums/search-artist")
def albums_search_artist(q: str):
"""Search MusicBrainz for an artist by name.
Returns up to 5 candidates with mbid, name, disambiguation, and score.
Returns 503 when MB is unreachable so the UI can show a Retry button
instead of pretending the artist does not exist.
"""
if not q or not q.strip():
raise HTTPException(status_code=400, detail="Query parameter 'q' is required")
try:
results = search_artist_mbid(q.strip())
except MusicBrainzUnavailable as exc:
raise HTTPException(status_code=503, detail=f"MusicBrainz unreachable: {exc}. Try again in a moment.")
return {"artists": results}
@app.get("/api/albums/artist/{mbid}/albums")
def albums_list_artist_albums(mbid: str, http_request: Request, artist_name: str = ""):
"""Fetch studio albums for a MusicBrainz artist MBID.
Returns [{title, year, release_mbid, release_group_mbid}, ...] sorted by year.
Pass ?artist_name= and each album also carries `on_disk`, so a tickable album
list can grey out the ones already sitting in the library rather than inviting
you to download your own record collection back to yourself. That check is
pure filesystem, no extra MusicBrainz traffic.
503 on MB unavailable.
"""
if not mbid or not mbid.strip():
raise HTTPException(status_code=400, detail="Artist MBID is required")
try:
albums = fetch_artist_albums(mbid.strip())
except MusicBrainzUnavailable as exc:
raise HTTPException(status_code=503, detail=f"MusicBrainz unreachable: {exc}. Try again in a moment.")
artist_name = (artist_name or "").strip()
if artist_name:
user_id = http_request.state.user_id
for album in albums:
album["on_disk"] = album_on_disk(artist_name, album.get("title") or "", user_id=user_id)
return {"albums": albums}
@app.get("/api/albums/release/{release_mbid}/tracks")
def albums_get_tracklist(release_mbid: str):
"""Fetch the tracklist for a MusicBrainz release MBID.
Returns [{position, title}, ...] in track order.
503 on MB unavailable.
"""
if not release_mbid or not release_mbid.strip():
raise HTTPException(status_code=400, detail="Release MBID is required")
try:
tracks = fetch_album_tracks(release_mbid.strip())
except MusicBrainzUnavailable as exc:
raise HTTPException(status_code=503, detail=f"MusicBrainz unreachable: {exc}. Try again in a moment.")
if not tracks:
raise HTTPException(status_code=404, detail="No tracks found for this release")
return {"tracks": tracks}
@app.get("/api/albums/release-group/{release_group_mbid}/tracks")
def albums_get_tracklist_by_group(release_group_mbid: str):
"""Resolve a release-group MBID to a representative release, then fetch its tracklist.
The Albums browser lists release-groups now (see fetch_artist_albums), so
it doesn't know which pressing to download until an album is actually
opened. This does that resolution here, once, for the one album clicked,
rather than for the whole list up front at one MusicBrainz request per
second. Returns {release_mbid, tracks: [{position, title}, ...]} so the
frontend can remember the resolved release for the rest of the session.
503 on MB unavailable, 404 if nothing could be resolved.
"""
if not release_group_mbid or not release_group_mbid.strip():
raise HTTPException(status_code=400, detail="Release group MBID is required")
try:
release_mbid = resolve_album_release(release_group_mbid.strip())
except MusicBrainzUnavailable as exc:
raise HTTPException(status_code=503, detail=f"MusicBrainz unreachable: {exc}. Try again in a moment.")
if not release_mbid:
raise HTTPException(status_code=404, detail="Could not find a release to download for this album")
try:
tracks = fetch_album_tracks(release_mbid)
except MusicBrainzUnavailable as exc:
raise HTTPException(status_code=503, detail=f"MusicBrainz unreachable: {exc}. Try again in a moment.")
if not tracks:
raise HTTPException(status_code=404, detail="No tracks found for this release")
return {"release_mbid": release_mbid, "tracks": tracks}
# _album_track_stem, album_track_status (formerly _album_track_status) and
# _normalise_album_match_text now live in albums.py, alongside the rest of the
# album pipeline (imported at the top of this file). _match_album_track below
# still leans on _normalise_album_match_text for its own fuzzy matching.
def _ensure_album_repair_idle(conn, album_dir: str) -> None:
busy = conn.execute(
"""SELECT 1 FROM jobs WHERE override_dir = ? AND status IN ('queued', 'downloading')
UNION ALL
SELECT 1 FROM bulk_imports WHERE override_dir = ? AND status IN ('pending', 'processing')
LIMIT 1""", (album_dir, album_dir),
).fetchone()
if busy:
raise HTTPException(status_code=409, detail="This album has a download in progress; wait for it to finish before repairing a track")
@app.post("/api/albums/repair-track")
def albums_repair_track(body: AlbumTrackRepairRequest, request: Request):
"""Prepare a manual rescue, keeping the bad copy recoverable in Trash."""
user_id = request.state.user_id
selection = body.model_dump(exclude={"trash_existing"})
try:
context = resolve_album_track_repair(**selection, user_id=user_id)
except MusicBrainzUnavailable as exc:
raise HTTPException(status_code=503, detail=f"MusicBrainz unreachable: {exc}")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
files = album_repair_files(context)
if len(files) > 1:
raise HTTPException(status_code=409, detail="Several files match this album track; remove the unwanted copies through Queue before searching for a replacement")
if files and not body.trash_existing:
raise HTTPException(status_code=409, detail="This track is on disk; confirm moving the current copy to Trash before replacing it")
with db_conn() as conn:
conn.execute("BEGIN IMMEDIATE")
_ensure_album_repair_idle(conn, context["override_dir"])
if files:
from settings import get_trash_dir
music_root = Path(get_setting("music_dir", str(MUSIC_DIR), user_id=user_id))
trash_path = get_trash_dir(user_id=user_id) / files[0].relative_to(music_root)
if trash_path.exists() or trash_path.with_suffix(".lrc").exists():
raise HTTPException(status_code=409, detail="An earlier copy is already in Trash; restore or remove it before replacing this track again")
from utils import move_to_trash
if move_to_trash(files[0], user_id=user_id) is None:
raise HTTPException(status_code=500, detail="Could not move the current copy to Trash; no replacement was prepared")
conn.execute("UPDATE jobs SET file_deleted = 1 WHERE final_path = ? AND user_id IS ?", (str(files[0]), user_id))
conn.commit()
_refresh_album_m3u_if_present(context["override_dir"])
selection.update({
"album_dir": context["override_dir"], "track_title": context["track_title"],
"disc_number": context["disc_number"], "track_number": context["track_number"],
})
return {"selection": selection, "context": context, "trashed": bool(files)}
def _match_album_track(tracks: list[dict], candidate_title: str) -> dict | None:
"""Return a single best album track match for a candidate title."""
cand = _normalise_album_match_text(candidate_title)
if not cand:
return None
exact = [t for t in tracks if _normalise_album_match_text(t.get("title") or "") == cand]
if len(exact) == 1:
return exact[0]
if len(exact) > 1:
return None
cand_tokens = set(cand.split())
fuzzy = []
for t in tracks:
tn = _normalise_album_match_text(t.get("title") or "")
if not tn:
continue
t_tokens = set(tn.split())
overlap = len(cand_tokens & t_tokens)
# Require ≥80% of the shorter token set to overlap; floor at 2 tokens to
# avoid matching on coincidental single words in short titles like "My Love".
min_overlap = max(2, round(0.8 * min(len(cand_tokens), len(t_tokens))))
if overlap >= min_overlap:
fuzzy.append((overlap, t))
if len(fuzzy) == 1:
return fuzzy[0][1]
return None
@app.post("/api/albums/release/{release_mbid}/match-track")
def albums_match_track(release_mbid: str, body: dict):
"""Match a candidate song to a specific track on the selected album."""
title = (body.get("title") or "").strip()
candidate_artist = (body.get("artist") or "").strip()
album_artist = (body.get("album_artist") or "").strip()
if not release_mbid or not release_mbid.strip():
raise HTTPException(status_code=400, detail="Release MBID is required")
if not title:
raise HTTPException(status_code=400, detail="Track title is required")
if candidate_artist and album_artist:
cand_artist_n = _normalise_album_match_text(candidate_artist)
album_artist_n = _normalise_album_match_text(album_artist)
if cand_artist_n and album_artist_n:
cand_tokens = {t for t in cand_artist_n.split() if len(t) > 1}
album_tokens = {t for t in album_artist_n.split() if len(t) > 1}
if album_tokens and not album_tokens.issubset(cand_tokens):
raise HTTPException(
status_code=422,
detail=f"Result artist '{candidate_artist}' does not match selected album artist '{album_artist}'",
)
tracks = fetch_album_tracks(release_mbid.strip())
if not tracks:
raise HTTPException(status_code=404, detail="No tracks found for this release")
matched = _match_album_track(tracks, title)
if not matched:
raise HTTPException(status_code=422, detail=f"No album-track match found for '{title}'")
try:
track_number = int((matched.get("position") or "").strip())
except Exception:
track_number = None
return {
"matched": True,
"track_title": (matched.get("title") or "").strip(),
"track_number": track_number,
"track_total": len(tracks),
}
@app.get("/api/albums/release/{release_mbid}/missing")
def albums_missing_tracks(release_mbid: str, artist: str, album_title: str, request: Request):
"""Report which tracks are already present on disk for a selected album."""
artist = (artist or "").strip()
album_title = (album_title or "").strip()
if not release_mbid or not release_mbid.strip():
raise HTTPException(status_code=400, detail="Release MBID is required")
if not artist or not album_title:
raise HTTPException(status_code=400, detail="artist and album_title are required")
try:
tracks = fetch_album_tracks(release_mbid.strip())
except MusicBrainzUnavailable as exc:
raise HTTPException(status_code=503, detail=f"MusicBrainz unreachable: {exc}. Try again in a moment.")
if not tracks:
raise HTTPException(status_code=404, detail="No tracks found for this release")
user_id = getattr(request.state, "user_id", None)
status = album_track_status(artist, album_title, tracks, user_id=user_id)
# Existing albums belong to the pressing recorded when they were queued,
# which may differ from today's representative release in the artist browser.
info_path = status["album_dir"] / ".albuminfo"
if info_path.is_file() and not info_path.is_symlink():
try:
info = json.loads(info_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
info = {}
stored_release = info.get("release_mbid")
if stored_release and stored_release != release_mbid:
try:
tracks = fetch_album_tracks(stored_release)
except MusicBrainzUnavailable as exc:
raise HTTPException(status_code=503, detail=f"MusicBrainz unreachable: {exc}. Try again in a moment.")
if not tracks:
raise HTTPException(status_code=404, detail="No tracks found for the album's stored release")
release_mbid = stored_release
status = album_track_status(artist, album_title, tracks, user_id=user_id)
return {
"release_mbid": release_mbid,
"artist": artist,
"album_title": album_title,
"album_dir": str(status["album_dir"]),
"total_tracks": len(status["tracks"]),
"tracks": status["tracks"],
"existing_count": len(status["existing_tracks"]),
"missing_count": len(status["missing_tracks"]),
"existing_tracks": status["existing_tracks"],
"missing_tracks": status["missing_tracks"],
"has_existing_m3u": bool(status["m3u_files"]),
"existing_m3u_files": status["m3u_files"],
}
@app.get("/api/albums/dirs")
@prioritise_over_audio_audit
def albums_list_artists(request: Request):
"""List artist folders found under the Albums directory on disk."""
user_id = getattr(request.state, "user_id", None)
base = get_albums_dir(user_id=user_id)
if not base.exists():
return {"artists": []}
artists = sorted(
[d.name for d in base.iterdir() if d.is_dir() and not d.name.startswith(".")],
key=str.casefold,
)
return {"artists": artists}
@app.get("/api/albums/dirs/{artist}")
@prioritise_over_audio_audit
def albums_list_albums(artist: str, request: Request):
"""List album folders within an artist directory."""
if ".." in artist:
raise HTTPException(status_code=400, detail="Invalid artist name")
user_id = getattr(request.state, "user_id", None)
base = get_albums_dir(user_id=user_id)
artist_dir = base / sanitize_filename(artist)
if not artist_dir.exists():
raise HTTPException(status_code=404, detail="Artist directory not found")
albums = sorted(
[d.name for d in artist_dir.iterdir() if d.is_dir() and not d.name.startswith(".")],
key=str.casefold,
)
return {"albums": albums}
@app.get("/api/albums/dirs/{artist}/{album}/info")
@prioritise_over_audio_audit
def albums_dir_info(artist: str, album: str, request: Request):
"""Read .albuminfo sidecar from an album directory and return MB tracklist."""
if ".." in artist or ".." in album:
raise HTTPException(status_code=400, detail="Invalid path")
user_id = getattr(request.state, "user_id", None)
base = get_albums_dir(user_id=user_id)
album_dir = base / sanitize_filename(artist) / sanitize_filename(album)
albuminfo_path = album_dir / ".albuminfo"
if not albuminfo_path.exists():
return {"found": False}
try:
data = json.loads(albuminfo_path.read_text(encoding="utf-8"))
release_mbid = (data.get("release_mbid") or "").strip()
tracks = fetch_album_tracks(release_mbid) if release_mbid else []
return {
"found": True,
"artist": data.get("artist", ""),
"album": data.get("album", ""),
"release_mbid": release_mbid,
"tracks": tracks,
}
except Exception:
return {"found": False}
@app.post("/api/albums/resolve-url")
def albums_resolve_url(body: PlaylistFetchRequest, http_request: Request):
"""Turn a MusicBrainz release or release-group URL into album download fields.
The MBID is right there in the URL, so there is nothing to scrape and nothing
to guess: we resolve it to artist/title and hand back exactly what
/api/albums/download wants. Release-group URLs (the ones MusicBrainz shows
you when you search) are resolved to a representative official release.
"""
url = (body.url or "").strip()
parsed = parse_musicbrainz_release_url(url)
if not parsed:
raise HTTPException(
status_code=400,
detail="Not a MusicBrainz release URL. Expected musicbrainz.org/release/<mbid> or /release-group/<mbid>.",
)
kind, mbid = parsed
try:
summary = fetch_release_summary(kind, mbid)
except MusicBrainzUnavailable as e:
raise HTTPException(status_code=503, detail=str(e))
if not summary:
raise HTTPException(status_code=404, detail="MusicBrainz has no release at that URL")
return summary
@app.get("/api/albums/search-release")
def albums_search_release(q: str, artist: str | None = None):
"""Search MusicBrainz for an album by name, with or without an artist.
Deliberately does not require an artist first: soundtracks, compilations
and Various Artists releases are first-class citizens here, not awkward
edge cases. Somebody typing "Trainspotting" should not have to nominate
which of its thirty-odd contributors counts as the owner.
release_mbid comes back null on purpose. Resolving a representative
release costs one rate-limited MusicBrainz request per candidate, and
nobody needs that for the nine albums they are about to ignore; call
/api/albums/resolve-release-group once the user has actually picked one.
"""
if not q or not q.strip():
raise HTTPException(status_code=400, detail="Query parameter 'q' is required")
try:
releases = search_release_groups(q.strip(), artist=(artist or "").strip() or None,
resolve_releases=False)
except MusicBrainzUnavailable as exc:
raise HTTPException(status_code=503, detail=f"MusicBrainz unreachable: {exc}. Try again in a moment.")
return {"releases": releases}
@app.post("/api/albums/resolve-release-group")
def albums_resolve_release_group(body: ReleaseGroupResolveRequest):
"""Turn a chosen release-group into concrete album download fields.
The other half of the lazy search above: one request, for the one album
the user actually wants, handing back exactly what /api/albums/download
expects.
"""
rg_mbid = (body.release_group_mbid or "").strip()
if not rg_mbid:
raise HTTPException(status_code=400, detail="release_group_mbid is required")
try:
summary = fetch_release_summary("release-group", rg_mbid)
except MusicBrainzUnavailable as exc:
raise HTTPException(status_code=503, detail=f"MusicBrainz unreachable: {exc}. Try again in a moment.")
if not summary:
raise HTTPException(status_code=404, detail="MusicBrainz has no usable release in that release group")
return summary
@app.post("/api/albums/resolve-album-url")
def albums_resolve_album_url(body: PlaylistFetchRequest):
"""Work out whether a pasted URL is an ALBUM on a supported service, and what it is.
Spotify, Apple Music, YouTube Music, Beatport, Tidal and Monochrome.
Returns recognised=False for anything else, including playlist and track
URLs from those same services, so the caller can fall through to the
ordinary playlist path without a fuss.
The confidence gate matters here: `confident` false means the caller must
ask the user which candidate they meant. Guessing wrong does not cost one
duff track, it costs a whole duff album.
"""
url = (body.url or "").strip()
if not url:
raise HTTPException(status_code=400, detail="url is required")
try:
return resolve_album_url(url)
except MusicBrainzUnavailable as exc:
raise HTTPException(status_code=503, detail=f"MusicBrainz unreachable: {exc}. Try again in a moment.")
@app.post("/api/albums/download")
def albums_download(body: AlbumDownloadRequest, http_request: Request):
"""Queue a full album for download.
Fetches tracklist from MusicBrainz, creates a bulk import job routed to
Albums/Artist/Album/ instead of the normal Singles layout.
Returns {import_id} for polling via /api/bulk-import/{id}/status.
The actual pipeline (tracklist fetch, existing/missing diff, .albuminfo
sidecar, in-flight guard, queueing) lives in albums.py so watched_artists.py
can reuse it for auto-added albums without importing this whole file.
"""
_enforce_peon_format(http_request, body)
user_id = getattr(http_request.state, "user_id", None)
artist = body.artist.strip()
album_title = body.album_title.strip()
release_mbid = body.release_mbid.strip()
if not artist or not album_title or not release_mbid:
raise HTTPException(status_code=400, detail="artist, album_title, and release_mbid are required")
try:
result = queue_album_download(
artist,
album_title,
release_mbid,
make_m3u=body.make_m3u,
m3u_name=(body.m3u_name or "").strip(),
convert_audio=body.convert_audio,
user_id=user_id,
)
except AlbumTracklistUnavailable as e:
raise HTTPException(status_code=404, detail=str(e))
# Picked by hand from a followed artist's album list? Record it as dealt with,
# otherwise the auto-add diff spots it as a shiny new arrival on the next
# refresh and queues the whole thing all over again.
watch_artist_id = (body.watch_artist_id or "").strip()
group_mbid = (body.release_group_mbid or "").strip() or release_mbid
if watch_artist_id:
try:
with db_conn() as conn:
conn.execute(
"""INSERT INTO watched_artist_albums
(artist_id, release_group_mbid, release_mbid, title, year, status,
queued_at, import_id)
VALUES (?, ?, ?, ?, '', 'queued', datetime('now'), ?)
ON CONFLICT(artist_id, release_group_mbid) DO UPDATE SET
status = 'queued',
queued_at = datetime('now'),
import_id = excluded.import_id""",
(watch_artist_id, group_mbid, release_mbid, album_title,
result.get("import_id"))
)
conn.commit()
except Exception as e:
# Bookkeeping only. The album is already queued and that is the bit
# the user cares about; worst case auto-add offers it again later.
print(f"Could not record hand-picked album for artist {watch_artist_id}: {e}")
return result
if __name__ == "__main__":
import uvicorn
host = os.getenv("LISTEN_ADDR", "0.0.0.0")
port = int(os.getenv("LISTEN_PORT", "8080"))
uvicorn.run(app, host=host, port=port)