Files
alembic/scripts/import_legacy_credentials.py
T
andrew a1f5bbb121 Fix regen.sh PATH bug; add legacy credential import script
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.
2026-07-08 14:47:39 -06:00

180 lines
6.5 KiB
Python

#!/usr/bin/env python3
"""
import_legacy_credentials.py — one-time Stage 1/2 migration step.
Reads every existing plaintext credential from its legacy scattered
location and populates alembic's encrypted credential store via
credential_service, which immediately re-renders everything fresh under
$ALEMBIC_CONFIG_DIR/pipeline/. Never writes to or modifies the legacy
files -- read-only against them. Idempotent (safe to re-run; each
credential is just an upsert).
Run playlist_service.seed_legacy() BEFORE this script, not after: the
soulseek/spotify credential render patches "every existing <playlist>.conf"
in $ALEMBIC_CONFIG_DIR/pipeline/ -- if no .conf files exist yet (no
playlists seeded), that render is a silent no-op.
Expects the legacy paths bind-mounted read-only at the /legacy/* paths
below -- see docs/MIGRATION.md for the one-off `docker run` invocation.
"""
import re
import sys
from pathlib import Path
sys.path.insert(0, "/app")
from app.db import SessionLocal, init_db # noqa: E402
from app.services import credential_service # noqa: E402
LEGACY_SLDL = Path("/legacy/opt-sldl")
LEGACY_BANDCAMP = Path("/legacy/bandcampconfig")
LEGACY_AZURACAST = Path("/legacy/azuracastconfig")
LEGACY_QOBUZ = Path("/legacy/qobuzconfig")
LEGACY_TELEGRAM = Path("/legacy/telegramconfig")
def parse_env_file(path: Path) -> dict[str, str]:
values = {}
if not path.exists():
return values
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
values[key.strip()] = value.strip().strip("'").strip('"')
return values
def import_spotify_and_soulseek(db) -> None:
# Every playlist .conf carries the same spotify-id/spotify-secret and
# user/pass (regen.sh templates them identically into each one) -- any
# single one is a representative source.
sample_conf = next(LEGACY_SLDL.glob("configs/*.conf"), None)
if sample_conf is None or sample_conf.name == "_template.conf":
sample_conf = LEGACY_SLDL / "configs" / "y2k.conf"
if not sample_conf.exists():
print(f"[import] SKIP spotify/soulseek: no playlist .conf found at {sample_conf}")
return
body = sample_conf.read_text()
def field(pattern):
m = re.search(pattern, body, re.MULTILINE)
return m.group(1).strip() if m else None
cid = field(r"^spotify-id\s*=\s*(\S+)")
csec = field(r"^spotify-secret\s*=\s*(\S+)")
if cid and csec:
credential_service.set_credentials(db, "spotify", {"client_id": cid, "client_secret": csec})
print("[import] spotify: OK")
else:
print("[import] SKIP spotify: fields not found")
user = field(r"^user\s*=\s*(\S+)")
pw = field(r"^pass\s*=\s*(\S+)")
if user and pw:
credential_service.set_credentials(db, "soulseek", {"username": user, "password": pw})
print("[import] soulseek: OK")
else:
print("[import] SKIP soulseek: fields not found")
def import_navidrome(db) -> None:
# Never lived in a file -- hardcoded across 8 legacy scripts. Extract
# the password from one of them rather than re-transcribing it by hand.
# base_url is deliberately NOT the old hardcoded LAN IP (192.168.0.72):
# those scripts ran on the bare host, but alembic runs on the compose
# network, so the container DNS name is the correct address now.
script = LEGACY_SLDL / "lib" / "navidrome-scan.sh"
if not script.exists():
print(f"[import] SKIP navidrome: {script} not found")
return
body = script.read_text()
m = re.search(r"^ND_PASS=['\"]?([^'\"\n]+)['\"]?", body, re.MULTILINE)
if not m:
print("[import] SKIP navidrome: ND_PASS not found in navidrome-scan.sh")
return
credential_service.set_credentials(
db,
"navidrome",
{"base_url": "http://navidrome:4533", "admin_user": "andrew", "admin_pass": m.group(1)},
)
print("[import] navidrome: OK")
def import_bandcamp(db) -> None:
config = parse_env_file(LEGACY_BANDCAMP / "config.env")
cookies_path = LEGACY_BANDCAMP / "cookies.txt"
values = {}
if "BANDCAMP_USERNAME" in config:
values["username"] = config["BANDCAMP_USERNAME"]
if "BANDCAMP_FORMAT_PREF" in config:
values["format_pref"] = config["BANDCAMP_FORMAT_PREF"]
if cookies_path.exists():
values["cookies_txt"] = cookies_path.read_text()
if values:
credential_service.set_credentials(db, "bandcamp", values)
print(f"[import] bandcamp: OK ({', '.join(k for k in values if k != 'cookies_txt')}"
f"{', cookies_txt' if 'cookies_txt' in values else ''})")
else:
print("[import] SKIP bandcamp: nothing found")
def import_azuracast(db) -> None:
key_path = LEGACY_AZURACAST / "api_key"
if not key_path.exists():
print(f"[import] SKIP azuracast: {key_path} not found")
return
credential_service.set_credentials(db, "azuracast", {"api_key": key_path.read_text().strip()})
print("[import] azuracast: OK")
def import_qobuz(db) -> None:
token_path = LEGACY_QOBUZ / "token"
if not token_path.exists():
print(f"[import] SKIP qobuz: {token_path} not found")
return
values = {"token": token_path.read_text().strip()}
# app_id/region are optional -- enrich-buy-url.py auto-scrapes app_id and
# defaults region if these files don't exist (they didn't, in this deployment).
for field, filename in (("app_id", "app_id"), ("region", "region")):
p = LEGACY_QOBUZ / filename
if p.exists():
values[field] = p.read_text().strip()
credential_service.set_credentials(db, "qobuz", values)
print(f"[import] qobuz: OK ({', '.join(values)})")
def import_telegram(db) -> None:
config = parse_env_file(LEGACY_TELEGRAM / "notify.env")
values = {}
if "TG_BOT_TOKEN" in config:
values["bot_token"] = config["TG_BOT_TOKEN"]
if "TG_CHAT_ID" in config:
values["chat_id"] = config["TG_CHAT_ID"]
if values:
credential_service.set_credentials(db, "telegram", values)
print("[import] telegram: OK")
else:
print("[import] SKIP telegram: nothing found")
def main():
init_db()
db = SessionLocal()
try:
import_spotify_and_soulseek(db)
import_navidrome(db)
import_bandcamp(db)
import_azuracast(db)
import_qobuz(db)
import_telegram(db)
finally:
db.close()
print("[import] done.")
if __name__ == "__main__":
main()