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 <playlist>.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.
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user