9a116f3da4
Credentials page now shows a fact-checked one-line description per service and marks Spotify/Soulseek/Navidrome as Required (no toggle -- they're load-bearing for every playlist sync). The four optional integrations (Bandcamp, AzuraCast, Qobuz, Telegram) get a real on/off switch: Bandcamp pauses its scheduled job (same mechanism as the Jobs page), the other three just remove their rendered credential file, which the scripts that read them already treat as "not configured, skip" -- no script changes needed. Disabled optional scopes also drop off the dashboard's credential status row. New Settings > Artist Casing page to view/add/remove entries in artist-canonical.list without shelling in -- adding a name that already matches case-insensitively replaces its casing in place instead of duplicating. Playlist detail page's status section is now two side-by-side columns: the original Spotify tracklist on the left, and a tabbed Downloaded / Waiting-to-download view on the right (client-side tabs via Alpine), both with an internal scroll area so long playlists don't pull the columns out of sync. Uses the existing status_service reconciliation (sldl index + beets + quarantine) -- no backend changes needed there.
310 lines
13 KiB
Python
310 lines
13 KiB
Python
import re
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models import AppSetting, 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"],
|
|
}
|
|
|
|
# One line explaining what each service is actually used for, shown on the
|
|
# credentials page so "should I bother setting this up" is answerable
|
|
# without reading the pipeline scripts.
|
|
SCOPE_DESCRIPTIONS = {
|
|
"spotify": "Reads playlist tracks and metadata (artist, title, album, genre) for every synced playlist, tag rewriting, and the genre refill job. Required.",
|
|
"soulseek": "Logs into Soulseek so sldl can search for and download tracks. Required.",
|
|
"navidrome": "Triggers a library rescan after downloads/imports/edits, and lets the daily status report check Navidrome's scan health. Required.",
|
|
"bandcamp": "Pulls new purchases from your Bandcamp collection, imports them automatically, and stamps a buy link into the file tag.",
|
|
"qobuz": "A buy-link source (alongside Bandcamp) for tracks you didn't purchase there -- DRM-free hi-res links when available, written directly into the file tag.",
|
|
"azuracast": "Not a buy-link source itself (that's Bandcamp/Qobuz, written straight to the file tag) -- this lets alembic tell AzuraCast (ephemeral.club's backend) to immediately reprocess touched files or fix a playlist assignment, instead of waiting for its periodic scan.",
|
|
"telegram": "Sends the daily pipeline health digest to a chat instead of you having to check the dashboard.",
|
|
}
|
|
|
|
# spotify/soulseek/navidrome are load-bearing for every playlist sync --
|
|
# there's no clean single place to "pause" them without silently breaking
|
|
# the whole pipeline, so they're always on and not shown with a toggle.
|
|
# The rest are optional add-ons that render to their own standalone
|
|
# credential file(s), which the scripts that read them already treat a
|
|
# missing/empty file as "not configured, skip this" -- see _clear_rendered().
|
|
CORE_SCOPES = {"spotify", "soulseek", "navidrome"}
|
|
OPTIONAL_SCOPES = {"bandcamp", "azuracast", "qobuz", "telegram"}
|
|
|
|
|
|
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}")
|
|
|
|
|
|
def _enabled_key(scope: str) -> str:
|
|
return f"cred_enabled:{scope}"
|
|
|
|
|
|
def is_scope_enabled(db: Session, scope: str) -> bool:
|
|
"""Core scopes are always enabled. Optional scopes default to enabled
|
|
(matches pre-existing behavior for anyone who already had them
|
|
configured before this toggle existed) until explicitly turned off."""
|
|
if scope in CORE_SCOPES:
|
|
return True
|
|
row = db.execute(select(AppSetting).where(AppSetting.key == _enabled_key(scope))).scalar_one_or_none()
|
|
return row is None or row.value == "1"
|
|
|
|
|
|
def _clear_rendered(scope: str) -> None:
|
|
"""Remove the standalone rendered credential file(s) for a disabled
|
|
optional scope, without touching the encrypted Secret rows -- the
|
|
script that reads each of these already treats a missing file as
|
|
"not configured, skip this source" (see enrich-buy-url.py's docstring
|
|
for qobuz/azuracast, and pipeline-status.sh's handling of a failed
|
|
notify-telegram.sh call). Re-enabling calls render_scope() again to
|
|
recreate the file from the still-stored values."""
|
|
if scope == "azuracast":
|
|
(settings.pipeline_config_dir / "azuracast" / "api_key").unlink(missing_ok=True)
|
|
elif scope == "qobuz":
|
|
qobuz_dir = settings.pipeline_config_dir / "qobuz"
|
|
for name in ("token", "app_id", "region"):
|
|
(qobuz_dir / name).unlink(missing_ok=True)
|
|
elif scope == "telegram":
|
|
(settings.pipeline_config_dir / "telegram" / "notify.env").unlink(missing_ok=True)
|
|
|
|
|
|
def set_scope_enabled(db: Session, scope: str, enabled: bool) -> None:
|
|
"""Toggle an optional integration on/off without discarding its saved
|
|
credentials. Bandcamp is the one optional scope with its own dedicated
|
|
scheduled job (maintenance:sync_bandcamp) -- that job hard-fails if its
|
|
cookies file goes missing (see sync-bandcamp.sh), so bandcamp is gated
|
|
by pausing the job itself (the same enable/disable machinery the Jobs
|
|
page already uses) rather than by removing its rendered files. The
|
|
other three scopes have no job of their own; the script that reads
|
|
them already skips gracefully on a missing file, so removing the
|
|
rendered file is enough."""
|
|
if scope not in OPTIONAL_SCOPES:
|
|
raise ValueError(f"{scope} cannot be toggled")
|
|
|
|
key = _enabled_key(scope)
|
|
row = db.execute(select(AppSetting).where(AppSetting.key == key)).scalar_one_or_none()
|
|
if row is None:
|
|
db.add(AppSetting(key=key, value="1" if enabled else "0"))
|
|
else:
|
|
row.value = "1" if enabled else "0"
|
|
db.commit()
|
|
|
|
if scope == "bandcamp":
|
|
from app.services import scheduler_service
|
|
|
|
scheduler = scheduler_service.get_scheduler()
|
|
if scheduler is not None:
|
|
scheduler_service.set_maintenance_enabled(scheduler, "maintenance:sync_bandcamp", enabled)
|
|
elif enabled:
|
|
render_scope(db, scope)
|
|
else:
|
|
_clear_rendered(scope)
|
|
|
|
|
|
def _bandcamp_cookie_expiry(cookies_txt: str) -> float | None:
|
|
"""Parse the Netscape-format cookie jar for the 'identity' cookie's
|
|
expiry (Unix timestamp) -- same field pipeline-status.sh already reads
|
|
via `awk -F'\\t' '$6=="identity" {print $5}'`. Purely local string
|
|
parsing, no network call, safe to run on every dashboard load."""
|
|
for line in cookies_txt.splitlines():
|
|
fields = line.split("\t")
|
|
if len(fields) >= 6 and fields[5] == "identity":
|
|
try:
|
|
return float(fields[4])
|
|
except ValueError:
|
|
return None
|
|
return None
|
|
|
|
|
|
def auth_states(db: Session) -> list[dict]:
|
|
"""Per-scope credential health for the dashboard: whether it's
|
|
configured at all, plus (bandcamp only) cookie expiry -- computed
|
|
locally from the stored cookie jar, not a live network probe (unlike
|
|
pipeline-status.sh's Qobuz check, which hits Qobuz's API on every run;
|
|
deliberately not replicated here since the dashboard renders on every
|
|
page load)."""
|
|
states = []
|
|
for scope in SCOPE_FIELDS:
|
|
if scope in OPTIONAL_SCOPES and not is_scope_enabled(db, scope):
|
|
continue # turned off -- don't clutter the dashboard with it
|
|
values = get_scope(db, scope)
|
|
configured = bool(values)
|
|
entry = {"scope": scope, "configured": configured}
|
|
if scope == "bandcamp" and values.get("cookies_txt"):
|
|
expiry = _bandcamp_cookie_expiry(values["cookies_txt"])
|
|
if expiry is not None:
|
|
entry["expires_at"] = expiry
|
|
entry["days_left"] = int((expiry - time.time()) / 86400)
|
|
states.append(entry)
|
|
return states
|