Files
andrew 3fbf580b88 0.6.2: Bypass sldl's Spotify client -- fetch the playlist ourselves, feed sldl a CSV
sldl's vendored Spotify client still calls GET /playlists/{id}/tracks, which
Spotify removed in its February 2026 API changes. Grandfathered apps still get
a pass; apps created after the change get a hard 403 there no matter how they
authenticate (client-credentials or a correctly-scoped OAuth user token), so
sldl can never load a playlist for a new app regardless of what we hand it.

Instead of depending on sldl's Spotify client at all, run-playlist.sh now reads
the playlist itself via the still-working /items endpoint (new
spotify-playlist-csv.py, creds sourced from _spotify.env) and invokes sldl with
the CSV + --input-type csv, which override the conf's input lines while -c still
supplies Soulseek login, paths, and quality settings (verified live). The same
CSV format upgrade-mp3-to-flac.sh already feeds sldl. All artists are
comma-joined in the CSV so multi-artist tracks search no worse than before, and
a 403/404 on the fetch prints the account-visibility hint instead of a bare
traceback.

Since sldl no longer talks to Spotify, playlist .confs no longer carry
spotify-id/spotify-secret/spotify-refresh: _template.conf drops them,
render_playlist_confs stops injecting them, and a Spotify credential save no
longer re-renders confs (only _spotify.env). The retag step in run-playlist.sh
reuses the sourced _spotify.env creds instead of scraping the conf lines that
no longer exist. Confs are also re-rendered once at app startup so
already-deployed confs converge on upgrade (and shed the stale secret lines)
without waiting for a playlist or credential change. Playlist delete now also
removes the generated .csv.

Tests updated: conf rendering must NOT contain Spotify creds but must keep the
input URL line; new coverage for _spotify.env rendering (quoting, refresh-token
presence/blank, 0600).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 08:48:30 -06:00

112 lines
4.1 KiB
Python

import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.exception_handlers import http_exception_handler
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.sessions import SessionMiddleware
from app.db import SessionLocal, enable_beets_db_wal, init_db, mark_interrupted_runs
from app.routers import artist_casing, auth, connect, credentials, dashboard, dedup, genres, health, import_, jobs, library, playlists
from app.security.session import resolve_session_secret
from app.services import scheduler_service
from app.settings import settings
log = logging.getLogger("alembic")
templates = Jinja2Templates(directory="app/templates")
async def not_found_or_default(request: Request, exc: StarletteHTTPException):
"""Render the branded 404 page for missing pages; leave every other
HTTP exception (including the 303 login redirect raised by require_auth)
to FastAPI's default handler so their status and headers are preserved."""
if exc.status_code == 404:
return templates.TemplateResponse(request, "404.html", {}, status_code=404)
return await http_exception_handler(request, exc)
def _warn_if_key_colocated_with_config() -> None:
"""The Fernet master key decrypts every stored credential. If it lives
inside the config volume, one leaked volume or backup exposes both the
key and the encrypted database together, which defeats the encryption.
Warn loudly rather than fail, since some users deliberately accept this."""
try:
key_path = settings.encryption_key_file.resolve()
config_dir = settings.alembic_config_dir.resolve()
except OSError:
return
if config_dir == key_path or config_dir in key_path.parents:
log.warning(
"Master key %s is inside the config directory %s. Anyone with a "
"copy of that volume or backup gets both the key and the encrypted "
"credentials. Mount the key from a separate location kept out of "
"the config backup. See docker-compose.snippet.yml.",
key_path,
config_dir,
)
def _render_confs_on_startup() -> None:
"""Re-render every playlist .conf from the current template once per boot,
so confs rendered by an older version converge on upgrade without waiting
for a playlist or credential change. Concretely: 0.6.2 dropped the Spotify
credential lines from confs (sldl no longer talks to Spotify), and this is
what scrubs those secrets from already-deployed confs. Best-effort -- a
render failure shouldn't stop the app from starting."""
from app.services import credential_service
db = SessionLocal()
try:
credential_service.render_playlist_confs(db)
except Exception:
log.exception("Startup playlist .conf render failed; continuing")
finally:
db.close()
@asynccontextmanager
async def lifespan(_app: FastAPI):
_warn_if_key_colocated_with_config()
init_db()
mark_interrupted_runs()
enable_beets_db_wal()
_render_confs_on_startup()
scheduler = scheduler_service.create_scheduler()
scheduler_service.register_all_jobs(scheduler)
scheduler.start()
yield
scheduler.shutdown(wait=False)
def create_app() -> FastAPI:
app = FastAPI(title="alembic", lifespan=lifespan)
app.add_exception_handler(StarletteHTTPException, not_found_or_default)
app.add_middleware(SessionMiddleware, secret_key=resolve_session_secret())
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(dashboard.router)
app.include_router(library.router)
app.include_router(import_.router)
app.include_router(dedup.router)
app.include_router(genres.router)
app.include_router(playlists.router)
app.include_router(credentials.router)
app.include_router(connect.router)
app.include_router(jobs.router)
app.include_router(artist_casing.router)
return app
app = create_app()