import json import subprocess import time from sqlalchemy import select from sqlalchemy.orm import Session from app.models import Playlist from app.settings import settings # The 17-entry array this project is migrating off of (was hardcoded in # /opt/sldl/configs/regen.sh). Used once by seed_legacy() during Stage 1/2 # of the migration; the DB is the source of truth from then on. cron_expr # values are the exact staggered slots from /etc/cron.d/sldl-maintenance, # preserved so cutover doesn't change anyone's download schedule. LEGACY_PLAYLISTS = { "digicore": ("https://open.spotify.com/playlist/6tWHtPBECZTkZHPKFA3Fq4", "0 0 * * *", False), "techno": ("https://open.spotify.com/playlist/5GlAkczmLWJFIJO4FvkLEZ", "0 1 * * *", False), "house": ("https://open.spotify.com/playlist/2XF8ye6O5xqzT4NSfIXdfJ", "30 1 * * *", False), "lofi": ("https://open.spotify.com/playlist/0qnyF6TAmuT3U6hFH2fiF1", "0 2 * * *", False), "y2k": ("https://open.spotify.com/playlist/0iaA7ZJS00aRxhMwZn2GCU", "30 2 * * *", False), "weeb": ("https://open.spotify.com/playlist/0599AbpsKRjsp2rlJI8jhf", "0 3 * * *", False), "shoegaze": ("https://open.spotify.com/playlist/3R4S3tmbVKjXQ1RPAmryC5", "30 3 * * *", False), "bass": ("https://open.spotify.com/playlist/2zkF4S16efaMmmzdIOe5w2", "0 4 * * *", False), "hiphop": ("https://open.spotify.com/playlist/42JxTtOLJ7gG1ybfyy7Vmi", "30 4 * * *", False), "modular": ("https://open.spotify.com/playlist/2kOfP2EM8dzdMYC6fXvlLb", "0 5 * * *", False), "hotdog": ("https://open.spotify.com/playlist/53HrOL0qw47G9ywYZz3kgX", "30 5 * * *", False), "kpop": ("https://open.spotify.com/playlist/31e2V512TIqr5JIfgkyseo", "0 6 * * *", False), "jungle": ("https://open.spotify.com/playlist/4LjLeXg6ElneQiTaIcAHhE", "30 6 * * *", False), "goldenera": ("https://open.spotify.com/playlist/57vArhigysJfgwB6CY4VXR", "0 7 * * *", False), "botanica": ("https://open.spotify.com/playlist/5KgQT9YWz3EqhIN4Jh69IU", "30 7 * * *", False), "hardcore": ("https://open.spotify.com/playlist/1J1lbzlQKligzr2LWaq9Ex", "0 23 * * *", False), "liked": ("https://open.spotify.com/playlist/4Z3qCYuU1sjNeNYO3Amzeo", "30 23 * * *", True), } 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: 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 the legacy hardcoded array (migration Stage 1/2). Skips any name that already exists. Returns the number of rows created.""" created = 0 for name, (url, cron_expr, no_m3u) in LEGACY_PLAYLISTS.items(): if get_by_name(db, name) is not None: continue create(db, name=name, spotify_url=url, no_m3u=no_m3u, cron_expr=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 — the caller (routers/playlists.py) is responsible for re-rendering credentials afterward via credential_service.render_scope(db, "soulseek"/"spotify").""" _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), "PATH": "/usr/bin:/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)