From 5eaae8e81309393eb6a99e8792bdc7a74b04b286 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 8 Jul 2026 13:32:37 -0600 Subject: [PATCH] Add playlist_service and credential_service playlist_service: CRUD for the playlists table, playlists.json export, regen.sh invocation, and a one-time seed_legacy() importing the 17-entry legacy array. Creating/updating a playlist automatically re-renders confs and re-patches credentials into any newly-generated .conf file. credential_service: encrypted credential storage (via security/crypto.py) and per-scope rendering to the exact files the pipeline scripts read -- every .conf (spotify/soulseek), navidrome/admin.env, bandcamp/config.env+cookies.txt, azuracast/api_key, qobuz/{token,app_id,region}, telegram/notify.env. set_credentials() batches multi-field saves so a render never sees a half-populated scope. Also fixes a real bug found via integration testing: regen.sh's embedded python3 -c snippet used backslash-escaped quotes inside a single-quoted bash string, which is invalid Python syntax (backslash passed through literally) -- switched to double-quoted bash wrapper + single-quoted Python strings. Verified end-to-end: seeded 17 playlists, regenerated all 17 .conf files, saved spotify/soulseek/navidrome credentials and confirmed correct patching into rendered files (including bash-sourcing admin.env with special characters in the password), and confirmed delete/create both correctly add/remove .conf files with credentials pre-patched on create. --- app/services/credential_service.py | 184 +++++++++++++++++++++++++++++ app/services/playlist_service.py | 165 ++++++++++++++++++++++++++ pipeline/configs/regen.sh | 6 +- pipeline/lib/mb-tags.sh | 0 pipeline/lib/prep-audio.sh | 0 pipeline/lib/tag-guard.sh | 0 6 files changed, 352 insertions(+), 3 deletions(-) create mode 100644 app/services/credential_service.py create mode 100644 app/services/playlist_service.py mode change 100644 => 100755 pipeline/lib/mb-tags.sh mode change 100644 => 100755 pipeline/lib/prep-audio.sh mode change 100644 => 100755 pipeline/lib/tag-guard.sh diff --git a/app/services/credential_service.py b/app/services/credential_service.py new file mode 100644 index 0000000..63995e2 --- /dev/null +++ b/app/services/credential_service.py @@ -0,0 +1,184 @@ +import re +import time +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import Secret +from app.security import crypto +from app.settings import settings + +# Which fields exist per scope, and whether each is safe to display back to +# the UI once saved (short opaque strings need never be shown again). +SCOPE_FIELDS = { + "spotify": ["client_id", "client_secret"], + "soulseek": ["username", "password"], + "navidrome": ["base_url", "admin_user", "admin_pass"], + "bandcamp": ["username", "format_pref", "cookies_txt"], + "azuracast": ["api_key"], + "qobuz": ["token", "app_id", "region"], + "telegram": ["bot_token", "chat_id"], +} + + +def _upsert(db: Session, scope: str, key: str, value: str) -> None: + row = db.execute( + select(Secret).where(Secret.scope == scope, Secret.key == key) + ).scalar_one_or_none() + encrypted = crypto.encrypt(value) + if row is None: + db.add(Secret(scope=scope, key=key, value_encrypted=encrypted, updated_at=time.time())) + else: + row.value_encrypted = encrypted + row.updated_at = time.time() + + +def set_credential(db: Session, scope: str, key: str, value: str) -> None: + """Set a single field and re-render immediately. For scopes with more + than one field, prefer set_credentials() so the render sees every field + at once instead of a transiently half-populated scope.""" + if scope not in SCOPE_FIELDS or key not in SCOPE_FIELDS[scope]: + raise ValueError(f"unknown credential {scope}.{key}") + _upsert(db, scope, key, value) + db.commit() + render_scope(db, scope) + + +def set_credentials(db: Session, scope: str, values: dict[str, str]) -> None: + """Set every given field for a scope in one transaction, then render + once — the way a UI form submission covering multiple fields should + call this, rather than looping set_credential() per field.""" + if scope not in SCOPE_FIELDS: + raise ValueError(f"unknown credential scope: {scope}") + unknown = set(values) - set(SCOPE_FIELDS[scope]) + if unknown: + raise ValueError(f"unknown fields for {scope}: {unknown}") + for key, value in values.items(): + _upsert(db, scope, key, value) + db.commit() + render_scope(db, scope) + + +def get_credential(db: Session, scope: str, key: str) -> str | None: + row = db.execute( + select(Secret).where(Secret.scope == scope, Secret.key == key) + ).scalar_one_or_none() + return crypto.decrypt(row.value_encrypted) if row else None + + +def get_scope(db: Session, scope: str) -> dict[str, str]: + rows = db.execute(select(Secret).where(Secret.scope == scope)).scalars() + return {row.key: crypto.decrypt(row.value_encrypted) for row in rows} + + +def _patch_conf_field(path: Path, key: str, value: str) -> None: + """Rewrite a single `key = value` line in an sldl .conf file, leaving + every other line (including PLAYLIST_NAME/SPOTIFY_URL substitutions + regen.sh already applied) untouched.""" + text = path.read_text() + pattern = re.compile(rf"^{re.escape(key)}\s*=.*$", re.MULTILINE) + new_line = f"{key} = {value}" + if pattern.search(text): + text = pattern.sub(new_line, text) + else: + text = text.rstrip("\n") + f"\n{new_line}\n" + path.write_text(text) + + +def _every_playlist_conf() -> list[Path]: + return sorted(settings.pipeline_config_dir.glob("*.conf")) + + +def _write_env_file(path: Path, values: dict[str, str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + lines = [f"{k}='{v}'" for k, v in values.items()] + path.write_text("\n".join(lines) + "\n") + path.chmod(0o600) + + +def render_scope(db: Session, scope: str) -> None: + values = get_scope(db, scope) + if not values: + return # nothing saved yet for this scope — nothing to render + + if scope == "spotify": + cid = values.get("client_id", "") + csec = values.get("client_secret", "") + for conf in _every_playlist_conf(): + _patch_conf_field(conf, "spotify-id", cid) + _patch_conf_field(conf, "spotify-secret", csec) + _write_env_file( + settings.pipeline_config_dir / "_spotify.env", + {"SPOTIFY_CLIENT_ID": cid, "SPOTIFY_CLIENT_SECRET": csec}, + ) + + elif scope == "soulseek": + user = values.get("username", "") + pw = values.get("password", "") + for conf in _every_playlist_conf(): + _patch_conf_field(conf, "user", user) + _patch_conf_field(conf, "pass", pw) + + elif scope == "navidrome": + _write_env_file( + settings.pipeline_config_dir / "navidrome" / "admin.env", + { + "ND_BASE": values.get("base_url", "http://navidrome:4533"), + "ND_USER": values.get("admin_user", "andrew"), + "ND_PASS": values.get("admin_pass", ""), + }, + ) + + elif scope == "bandcamp": + bandcamp_dir = settings.pipeline_config_dir / "bandcamp" + bandcamp_dir.mkdir(parents=True, exist_ok=True) + _write_env_file( + bandcamp_dir / "config.env", + { + "BANDCAMP_USERNAME": values.get("username", ""), + "BANDCAMP_FORMAT_PREF": values.get("format_pref", "flac"), + "BANDCAMP_COOKIES": str(bandcamp_dir / "cookies.txt"), + "BANDCAMP_STATE": str(bandcamp_dir / "state.json"), + "BANDCAMP_STAGING": str( + settings.music_data_dir / "sldl-dropbox" / "_bandcamp-staging" + ), + }, + ) + cookies_txt = values.get("cookies_txt", "") + if cookies_txt: + cookies_path = bandcamp_dir / "cookies.txt" + cookies_path.write_text(cookies_txt) + cookies_path.chmod(0o600) + + elif scope == "azuracast": + az_dir = settings.pipeline_config_dir / "azuracast" + az_dir.mkdir(parents=True, exist_ok=True) + key_path = az_dir / "api_key" + key_path.write_text(values.get("api_key", "")) + key_path.chmod(0o600) + + elif scope == "qobuz": + qobuz_dir = settings.pipeline_config_dir / "qobuz" + qobuz_dir.mkdir(parents=True, exist_ok=True) + for field, filename in ( + ("token", "token"), + ("app_id", "app_id"), + ("region", "region"), + ): + if field in values: + path = qobuz_dir / filename + path.write_text(values[field]) + path.chmod(0o600) + + elif scope == "telegram": + _write_env_file( + settings.pipeline_config_dir / "telegram" / "notify.env", + { + "TG_BOT_TOKEN": values.get("bot_token", ""), + "TG_CHAT_ID": values.get("chat_id", ""), + }, + ) + + else: + raise ValueError(f"unknown credential scope: {scope}") diff --git a/app/services/playlist_service.py b/app/services/playlist_service.py new file mode 100644 index 0000000..a5ea648 --- /dev/null +++ b/app/services/playlist_service.py @@ -0,0 +1,165 @@ +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") diff --git a/pipeline/configs/regen.sh b/pipeline/configs/regen.sh index 18e791d..c988bfe 100755 --- a/pipeline/configs/regen.sh +++ b/pipeline/configs/regen.sh @@ -33,13 +33,13 @@ fi mkdir -p "$CONFIG_OUT_DIR" # One "namespotify_url" line per playlist, active or not (see header). -python3 -c ' +python3 -c " import json, sys with open(sys.argv[1]) as f: playlists = json.load(f) for p in playlists: - print(f"{p[\"name\"]}\t{p[\"spotify_url\"]}") -' "$PLAYLISTS_JSON" | while IFS=$'\t' read -r name url; do + print(p['name'] + '\t' + p['spotify_url']) +" "$PLAYLISTS_JSON" | while IFS=$'\t' read -r name url; do [[ -z "$name" ]] && continue sed -e "s|PLAYLIST_NAME|${name}|g" \ -e "s|SPOTIFY_URL|${url}|g" \ diff --git a/pipeline/lib/mb-tags.sh b/pipeline/lib/mb-tags.sh old mode 100644 new mode 100755 diff --git a/pipeline/lib/prep-audio.sh b/pipeline/lib/prep-audio.sh old mode 100644 new mode 100755 diff --git a/pipeline/lib/tag-guard.sh b/pipeline/lib/tag-guard.sh old mode 100644 new mode 100755