3fbf580b88
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>
112 lines
4.1 KiB
Python
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()
|