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. LEGACY_PLAYLISTS = { "techno": "https://open.spotify.com/playlist/5GlAkczmLWJFIJO4FvkLEZ", "house": "https://open.spotify.com/playlist/2XF8ye6O5xqzT4NSfIXdfJ", "lofi": "https://open.spotify.com/playlist/0qnyF6TAmuT3U6hFH2fiF1", "y2k": "https://open.spotify.com/playlist/0iaA7ZJS00aRxhMwZn2GCU", "weeb": "https://open.spotify.com/playlist/0599AbpsKRjsp2rlJI8jhf", "shoegaze": "https://open.spotify.com/playlist/3R4S3tmbVKjXQ1RPAmryC5", "bass": "https://open.spotify.com/playlist/2zkF4S16efaMmmzdIOe5w2", "hiphop": "https://open.spotify.com/playlist/42JxTtOLJ7gG1ybfyy7Vmi", "modular": "https://open.spotify.com/playlist/2kOfP2EM8dzdMYC6fXvlLb", "hotdog": "https://open.spotify.com/playlist/53HrOL0qw47G9ywYZz3kgX", "kpop": "https://open.spotify.com/playlist/31e2V512TIqr5JIfgkyseo", "jungle": "https://open.spotify.com/playlist/4LjLeXg6ElneQiTaIcAHhE", "goldenera": "https://open.spotify.com/playlist/57vArhigysJfgwB6CY4VXR", "botanica": "https://open.spotify.com/playlist/5KgQT9YWz3EqhIN4Jh69IU", "digicore": "https://open.spotify.com/playlist/6tWHtPBECZTkZHPKFA3Fq4", "hardcore": "https://open.spotify.com/playlist/1J1lbzlQKligzr2LWaq9Ex", "liked": "https://open.spotify.com/playlist/4Z3qCYuU1sjNeNYO3Amzeo", } 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, ) -> Playlist: now = time.time() playlist = Playlist( name=name, spotify_url=spotify_url, active=active, no_m3u=no_m3u, notes=notes, 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) 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 in LEGACY_PLAYLISTS.items(): if get_by_name(db, name) is not None: continue no_m3u = name == "liked" # matches --no-m3u liked in the old crontab create(db, name=name, spotify_url=url, no_m3u=no_m3u) 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 and immediately re-patch credentials into any newly-rendered file.""" regenerate_confs(db) # Imported lazily to avoid a circular import (credential_service doesn'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 both services grow). from app.services import credential_service credential_service.render_scope(db, "soulseek") credential_service.render_scope(db, "spotify")