Files
alembic/app/services/playlist_service.py
T
andrew a9e1263b01 Render playlist configs directly in-app; retire regen.sh and playlists.json (R3)
The app owns every input to a playlist's sldl .conf (playlists in its DB,
credentials in its encrypted store), so the old
DB -> playlists.json -> regen.sh subprocess -> regex credential patch-back
chain was three serialization hops for no reason. Collapse it:

- credential_service.render_playlist_confs(db) renders each <playlist>.conf
  from _template.conf in one pass: path placeholders substituted as literal
  text, then the four credential lines set, written 0600. This is now the
  single source of .conf rendering.
- playlist_service loses _write_playlists_json and regenerate_confs; _sync_to_disk
  just calls render_playlist_confs and syncs the scheduler. No subprocess, no
  intermediate JSON file.
- render_scope for spotify/soulseek re-renders confs via the same function
  (spotify still also writes _spotify.env for the python scripts). The dead
  _patch_conf_field / _every_playlist_conf helpers are removed.
- Delete pipeline/configs/regen.sh and drop it from the Dockerfile chmod;
  update _template.conf's comments.

Nothing outside playlist_service consumed regen.sh or playlists.json (verified).
Also closes the last remnants of the S2/S3 injection surface: names are
re-validated and path/credential values are substituted as literals, never
through sed or a shell.

Verified: end-to-end render in a throwaway DB (paths, creds incl. a password
with shell metacharacters, 0600, bad-name rejection) and a live re-render of
all 17 playlist configs with credentials preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:34:53 -06:00

185 lines
6.2 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 file.
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)
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)