a9e1263b01
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>
185 lines
6.2 KiB
Python
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)
|