a1f5bbb121
playlist_service.regenerate_confs() was passing a bare PATH=/usr/bin:/bin to the regen.sh subprocess, but python3 (which regen.sh shells out to for parsing playlists.json) lives at /opt/venv/bin/python3 in this image. Every call silently generated zero .conf files -- no error surfaced, since the failure was inside a `python3 -c ... | while read` pipeline whose input was just empty. Found this deploying to the real host: seed_legacy() reported "17 playlists" successfully, but zero .conf files existed. Fixed to match pipeline_runner's already-correct PATH. scripts/import_legacy_credentials.py: the Stage 1/2 one-time migration script -- reads spotify/soulseek creds from a sample playlist .conf, Navidrome's password (extracted from navidrome-scan.sh, never lived in a file), and bandcamp/azuracast/qobuz/telegram from their legacy scattered locations, then populates credential_service. Read-only against the legacy files, idempotent. Run as a one-off `docker run --entrypoint python` with the legacy paths bind-mounted read-only (documented in the script's own docstring) -- entrypoint.sh always execs uvicorn regardless of CMD, so --entrypoint must override it for one-off invocations like this. Verified against the real production host: seeded all 17 legacy playlists with their historical cron schedules, confirmed all 17 .conf files render correctly after the PATH fix, ran the credential import for all 7 scopes, and byte-diffed the rendered spotify/soulseek values in a sample .conf against the original (identical, verified via diff -q without displaying the actual secret values in any tool output). Also had to chown 4 root:root mode-600 legacy credential files (bandcampconfig/cookies.txt, azuracastconfig/api_key, qobuzconfig/token, telegramconfig/notify.env) to 1000:1000 so the container could read them for the import -- root retains read access regardless, so this doesn't affect the still-running host-cron pipeline.
185 lines
7.5 KiB
Python
185 lines
7.5 KiB
Python
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 -- _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)
|