import json import re import subprocess 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 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) _write_playlists_json(db) 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 _write_playlists_json(db: Session) -> None: """The file regen.sh reads. Every active-or-not playlist is included (see regen.sh's header comment) so a manual run is always possible; the scheduler is what actually skips inactive ones.""" playlists = list_all(db) payload = [ { "name": p.name, "spotify_url": p.spotify_url, "active": p.active, "no_m3u": p.no_m3u, } for p in playlists ] settings.pipeline_config_dir.mkdir(parents=True, exist_ok=True) out_path = settings.pipeline_config_dir / "playlists.json" out_path.write_text(json.dumps(payload, indent=2)) def regenerate_confs(db: Session) -> subprocess.CompletedProcess: """Write playlists.json, then run regen.sh to render/refresh every playlist's .conf file. Newly-created .conf files still have unpatched SOULSEEK_USER/SOULSEEK_PASS/SPOTIFY_CLIENT_ID/SPOTIFY_CLIENT_SECRET placeholders at this point -- _sync_to_disk() (this function's caller) re-renders credentials into them immediately afterward.""" _write_playlists_json(db) regen_script = settings.pipeline_dir / "configs" / "regen.sh" return subprocess.run( [str(regen_script)], capture_output=True, text=True, env={ "ALEMBIC_CONFIG_DIR": str(settings.alembic_config_dir), "MUSIC_DATA_DIR": str(settings.music_data_dir), "PIPELINE_DIR": str(settings.pipeline_dir), # Must include /opt/venv/bin: regen.sh shells out to `python3` # to parse playlists.json, and that's where this image's Python # actually lives (confirmed against the real container -- a # bare /usr/bin:/bin PATH made every regen.sh call silently # generate zero .conf files, no error surfaced to the caller). "PATH": "/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", }, ) def _sync_to_disk(db: Session) -> None: """Convenience wrapper used by create()/update(): regenerate confs, immediately re-patch credentials into any newly-rendered file, 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.""" regenerate_confs(db) # Imported lazily to avoid a circular import (credential_service and # scheduler_service don't depend on playlist_service, but importing at # module load time would still work here — done lazily anyway to keep # the dependency direction obvious as all three services grow). from app.services import credential_service, scheduler_service credential_service.render_scope(db, "soulseek") credential_service.render_scope(db, "spotify") scheduler = scheduler_service.get_scheduler() if scheduler is not None: scheduler_service.sync_playlist_jobs(scheduler)