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>
189 lines
6.4 KiB
Python
189 lines
6.4 KiB
Python
import json
|
|
import re
|
|
import time
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models import Playlist
|
|
from app.settings import settings
|
|
|
|
# A playlist name becomes a filename (<name>.conf), a directory under the
|
|
# Soulseek dropbox, an M3U path, and a log path, and it is substituted into
|
|
# rendered config files. Restrict it to a small safe charset so it can never
|
|
# escape those paths (../) or inject config/shell content. Kept deliberately
|
|
# tight; if a user wants a fancier display name that's a separate field to add
|
|
# later, not a reason to loosen the on-disk identifier.
|
|
_VALID_NAME = re.compile(r"^[A-Za-z0-9 _-]{1,64}$")
|
|
|
|
|
|
def validate_name(name: str) -> str:
|
|
"""Return the name unchanged if it is a safe on-disk identifier, else
|
|
raise ValueError with a message suitable for showing to the user."""
|
|
cleaned = (name or "").strip()
|
|
if not _VALID_NAME.match(cleaned):
|
|
raise ValueError(
|
|
"Playlist name must be 1-64 characters using only letters, "
|
|
"numbers, spaces, hyphens and underscores."
|
|
)
|
|
return cleaned
|
|
|
|
|
|
def cron_to_time(cron_expr: str | None) -> str:
|
|
"""'30 1 * * *' -> '01:30'. Every playlist schedule today is a simple
|
|
daily HH:MM (no day-of-week/day-of-month complexity -- that's only used
|
|
by a couple of maintenance jobs), so the UI can offer a plain time
|
|
picker instead of raw cron syntax. Returns '' for anything else
|
|
(unscheduled, or a cron expression more complex than daily-at-HH:MM)."""
|
|
if not cron_expr:
|
|
return ""
|
|
parts = cron_expr.split()
|
|
if len(parts) == 5 and parts[2] == "*" and parts[3] == "*" and parts[4] == "*":
|
|
try:
|
|
hour, minute = int(parts[1]), int(parts[0])
|
|
if 0 <= hour < 24 and 0 <= minute < 60:
|
|
return f"{hour:02d}:{minute:02d}"
|
|
except ValueError:
|
|
pass
|
|
return ""
|
|
|
|
|
|
def time_to_cron(time_str: str | None) -> str | None:
|
|
"""'01:30' -> '30 1 * * *'. Empty/invalid input means unscheduled."""
|
|
if not time_str:
|
|
return None
|
|
try:
|
|
hour_str, minute_str = time_str.split(":")
|
|
hour, minute = int(hour_str), int(minute_str)
|
|
except ValueError:
|
|
return None
|
|
if not (0 <= hour < 24 and 0 <= minute < 60):
|
|
return None
|
|
return f"{minute} {hour} * * *"
|
|
|
|
def list_all(db: Session) -> list[Playlist]:
|
|
return list(db.execute(select(Playlist).order_by(Playlist.name)).scalars())
|
|
|
|
|
|
def get(db: Session, playlist_id: int) -> Playlist | None:
|
|
return db.get(Playlist, playlist_id)
|
|
|
|
|
|
def get_by_name(db: Session, name: str) -> Playlist | None:
|
|
return db.execute(select(Playlist).where(Playlist.name == name)).scalar_one_or_none()
|
|
|
|
|
|
def create(
|
|
db: Session,
|
|
name: str,
|
|
spotify_url: str,
|
|
active: bool = True,
|
|
no_m3u: bool = False,
|
|
notes: str | None = None,
|
|
cron_expr: str | None = None,
|
|
) -> Playlist:
|
|
name = validate_name(name)
|
|
now = time.time()
|
|
playlist = Playlist(
|
|
name=name,
|
|
spotify_url=spotify_url,
|
|
active=active,
|
|
no_m3u=no_m3u,
|
|
notes=notes,
|
|
cron_expr=cron_expr,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
db.add(playlist)
|
|
db.commit()
|
|
db.refresh(playlist)
|
|
_sync_to_disk(db)
|
|
return playlist
|
|
|
|
|
|
def update(db: Session, playlist_id: int, **fields) -> Playlist:
|
|
playlist = db.get(Playlist, playlist_id)
|
|
if playlist is None:
|
|
raise ValueError(f"no playlist with id={playlist_id}")
|
|
for key, value in fields.items():
|
|
setattr(playlist, key, value)
|
|
playlist.updated_at = time.time()
|
|
db.commit()
|
|
db.refresh(playlist)
|
|
_sync_to_disk(db)
|
|
return playlist
|
|
|
|
|
|
def delete(db: Session, playlist_id: int) -> None:
|
|
"""Remove the playlist from the DB and delete its rendered .conf and
|
|
generated search .csv files.
|
|
Does NOT touch anything already downloaded/imported for it — that's a
|
|
library decision, not a playlist-definition one."""
|
|
playlist = db.get(Playlist, playlist_id)
|
|
if playlist is None:
|
|
return
|
|
name = playlist.name
|
|
db.delete(playlist)
|
|
db.commit()
|
|
conf_path = settings.pipeline_config_dir / f"{name}.conf"
|
|
conf_path.unlink(missing_ok=True)
|
|
# The search CSV run-playlist.sh generates next to the conf each run.
|
|
csv_path = settings.pipeline_config_dir / f"{name}.csv"
|
|
csv_path.unlink(missing_ok=True)
|
|
|
|
from app.services import scheduler_service
|
|
|
|
scheduler = scheduler_service.get_scheduler()
|
|
if scheduler is not None:
|
|
scheduler_service.sync_playlist_jobs(scheduler)
|
|
|
|
|
|
def seed_legacy(db: Session) -> int:
|
|
"""One-time import of playlists from an optional legacy-playlists.json in
|
|
the config dir (migration helper). This is not personal data baked into
|
|
the code: point it at your own export. Each entry is an object with
|
|
"name", "spotify_url", and optional "cron_expr" and "no_m3u". Skips any
|
|
name that already exists. Returns the number of rows created, or 0 if the
|
|
file is absent.
|
|
|
|
Example legacy-playlists.json:
|
|
[{"name": "techno", "spotify_url": "https://open.spotify.com/playlist/...",
|
|
"cron_expr": "0 1 * * *", "no_m3u": false}]
|
|
"""
|
|
legacy_path = settings.pipeline_config_dir / "legacy-playlists.json"
|
|
if not legacy_path.exists():
|
|
return 0
|
|
entries = json.loads(legacy_path.read_text())
|
|
created = 0
|
|
for entry in entries:
|
|
name = entry["name"]
|
|
if get_by_name(db, name) is not None:
|
|
continue
|
|
create(
|
|
db,
|
|
name=name,
|
|
spotify_url=entry["spotify_url"],
|
|
no_m3u=entry.get("no_m3u", False),
|
|
cron_expr=entry.get("cron_expr"),
|
|
)
|
|
created += 1
|
|
return created
|
|
|
|
|
|
def _sync_to_disk(db: Session) -> None:
|
|
"""Called by create()/update()/delete(): re-render every playlist's sldl
|
|
.conf (with credentials substituted, in-app -- no regen.sh subprocess or
|
|
playlists.json round-trip anymore) and push the change to the live
|
|
scheduler if one is running. This is what makes add/remove-in-the-UI take
|
|
effect without a redeploy.
|
|
|
|
Imported lazily to keep the dependency direction obvious (credential_service
|
|
and scheduler_service don't import playlist_service at module load)."""
|
|
from app.services import credential_service, scheduler_service
|
|
|
|
credential_service.render_playlist_confs(db)
|
|
|
|
scheduler = scheduler_service.get_scheduler()
|
|
if scheduler is not None:
|
|
scheduler_service.sync_playlist_jobs(scheduler)
|