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 (.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)