diff --git a/app/main.py b/app/main.py index b554252..72bae7d 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from app.db import enable_beets_db_wal, init_db -from app.routers import auth, credentials, dashboard, dedup, genres, import_, jobs, library, playlists +from app.routers import artist_casing, auth, credentials, dashboard, dedup, genres, import_, jobs, library, playlists from app.services import scheduler_service from app.settings import settings @@ -40,6 +40,7 @@ def create_app() -> FastAPI: app.include_router(playlists.router) app.include_router(credentials.router) app.include_router(jobs.router) + app.include_router(artist_casing.router) return app diff --git a/app/routers/artist_casing.py b/app/routers/artist_casing.py new file mode 100644 index 0000000..404b2f7 --- /dev/null +++ b/app/routers/artist_casing.py @@ -0,0 +1,29 @@ +from fastapi import APIRouter, Depends, Form, Request +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates + +from app.security.deps import require_auth +from app.services import artist_canonical_service + +router = APIRouter(prefix="/settings/artist-casing", tags=["artist-casing"]) +templates = Jinja2Templates(directory="app/templates") + + +@router.get("") +async def artist_casing_index(request: Request, user: dict = Depends(require_auth)): + entries = artist_canonical_service.list_entries() + return templates.TemplateResponse( + request, "settings/artist_casing.html", {"entries": entries} + ) + + +@router.post("/add") +async def add_artist(name: str = Form(...), user: dict = Depends(require_auth)): + artist_canonical_service.add_entry(name) + return RedirectResponse(url="/settings/artist-casing", status_code=303) + + +@router.post("/remove") +async def remove_artist(name: str = Form(...), user: dict = Depends(require_auth)): + artist_canonical_service.remove_entry(name) + return RedirectResponse(url="/settings/artist-casing", status_code=303) diff --git a/app/routers/credentials.py b/app/routers/credentials.py index 80cb0d7..ae414dd 100644 --- a/app/routers/credentials.py +++ b/app/routers/credentials.py @@ -18,10 +18,20 @@ async def credentials_index(request: Request, user: dict = Depends(require_auth) scope: set(credential_service.get_scope(db, scope).keys()) for scope in credential_service.SCOPE_FIELDS } + enabled = { + scope: credential_service.is_scope_enabled(db, scope) + for scope in credential_service.SCOPE_FIELDS + } return templates.TemplateResponse( request, "credentials/index.html", - {"scope_fields": credential_service.SCOPE_FIELDS, "configured": configured}, + { + "scope_fields": credential_service.SCOPE_FIELDS, + "scope_descriptions": credential_service.SCOPE_DESCRIPTIONS, + "core_scopes": credential_service.CORE_SCOPES, + "configured": configured, + "enabled": enabled, + }, ) @@ -39,3 +49,12 @@ async def save_credentials( } credential_service.set_credentials(db, scope, values) return RedirectResponse(url="/settings/credentials", status_code=303) + + +@router.post("/{scope}/toggle") +async def toggle_scope(scope: str, user: dict = Depends(require_auth), db=Depends(get_db)): + if scope not in credential_service.OPTIONAL_SCOPES: + raise HTTPException(404, "unknown or non-toggleable credential scope") + currently_enabled = credential_service.is_scope_enabled(db, scope) + credential_service.set_scope_enabled(db, scope, not currently_enabled) + return RedirectResponse(url="/settings/credentials", status_code=303) diff --git a/app/services/artist_canonical_service.py b/app/services/artist_canonical_service.py new file mode 100644 index 0000000..5cc95cd --- /dev/null +++ b/app/services/artist_canonical_service.py @@ -0,0 +1,55 @@ +from pathlib import Path + +from app.settings import settings + +# Instance state, not baked into the image -- seeded from +# pipeline/configs/artist-canonical.list.example on first boot (see +# entrypoint.sh) and edited from here after that. Read directly by +# normalize-artist-casing.py at run time, same file, no caching. + + +def _path() -> Path: + return settings.pipeline_config_dir / "artist-canonical.list" + + +def _is_entry_line(line: str) -> bool: + stripped = line.strip() + return bool(stripped) and not stripped.startswith("#") + + +def list_entries() -> list[str]: + path = _path() + if not path.exists(): + return [] + return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if _is_entry_line(line)] + + +def add_entry(name: str) -> None: + """Append a canonical name, or if one already matches case-insensitively + (the whole point of this list is one canonical casing per artist), + replace that line's casing instead of adding a duplicate.""" + name = name.strip() + if not name: + raise ValueError("artist name cannot be empty") + + path = _path() + path.parent.mkdir(parents=True, exist_ok=True) + lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else [] + + for i, line in enumerate(lines): + if _is_entry_line(line) and line.strip().lower() == name.lower(): + lines[i] = name + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return + + lines.append(name) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def remove_entry(name: str) -> None: + path = _path() + if not path.exists(): + return + lines = path.read_text(encoding="utf-8").splitlines() + lines = [line for line in lines if not (_is_entry_line(line) and line.strip() == name)] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") diff --git a/app/services/credential_service.py b/app/services/credential_service.py index cce2771..b1248b4 100644 --- a/app/services/credential_service.py +++ b/app/services/credential_service.py @@ -5,7 +5,7 @@ from pathlib import Path from sqlalchemy import select from sqlalchemy.orm import Session -from app.models import Secret +from app.models import AppSetting, Secret from app.security import crypto from app.settings import settings @@ -21,6 +21,28 @@ SCOPE_FIELDS = { "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( @@ -184,6 +206,71 @@ def render_scope(db: Session, scope: str) -> None: 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 @@ -208,6 +295,8 @@ def auth_states(db: Session) -> list[dict]: 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} diff --git a/app/static/style.css b/app/static/style.css index d469137..a2cb705 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -44,6 +44,8 @@ * { box-sizing: border-box; } +[x-cloak] { display: none !important; } + html { color-scheme: dark; } body { @@ -515,6 +517,61 @@ tbody td.actions-cell { display: flex; gap: 0.5rem; flex-wrap: wrap; } .status-legend .item strong { color: var(--fg); } .status-legend .dot { width: 0.6em; height: 0.6em; border-radius: 50%; box-shadow: 0 0 7px currentColor; } +/* ---- playlist compare (original vs. downloaded/waiting) ---- */ + +.playlist-compare { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + align-items: start; +} +@media (max-width: 860px) { + .playlist-compare { grid-template-columns: 1fr; } +} + +.compare-col-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.75rem; +} +.compare-col-header h3 { margin: 0; font-size: 1rem; } + +/* Fixed-height scroll area so a 150+ track playlist doesn't make the two + columns drift out of vertical sync -- the point of side-by-side columns + is comparing them at a glance, which only works if both stay on screen + together. Sticky header keeps column labels visible while scrolling. */ +.compare-scroll { max-height: 30rem; overflow-y: auto; } +.compare-scroll thead th { position: sticky; top: 0; background: var(--bg-soft); z-index: 1; } + +.seg-tabs { + display: inline-flex; + gap: 0.2rem; + padding: 0.2rem; + border-radius: 999px; + background: var(--panel); + border: 1px solid var(--border); + margin-bottom: 0.9rem; +} +.seg-tabs button { + font: inherit; + font-weight: 700; + font-size: 0.8rem; + padding: 0.4rem 0.9rem; + border: none; + border-radius: 999px; + background: transparent; + color: var(--muted); + cursor: pointer; + transition: color 0.15s ease, background 0.15s ease; + white-space: nowrap; +} +.seg-tabs button:hover { color: var(--fg); } +.seg-tabs button.active { color: #08090d; background: var(--gradient-holo); background-size: 200% auto; } + +.status-dot-info { background: var(--status-info); box-shadow: 0 0 6px var(--status-info); } + /* ---- misc ---- */ .notice { @@ -586,6 +643,54 @@ tbody td.actions-cell { display: flex; gap: 0.5rem; flex-wrap: wrap; } .cred-grid { grid-template-columns: 1fr; } } +.cred-scope-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.1rem; +} +.cred-scope-header h2 { margin: 0 0 0.35rem; text-transform: capitalize; } +.cred-desc { font-size: 0.85rem; margin: 0; max-width: 46ch; } +.cred-scope.cred-disabled .panel { opacity: 0.55; } +.cred-disabled-note { font-size: 0.78rem; color: var(--status-warning); margin: -0.5rem 0 1.1rem; } + +/* ---- toggle switch ---- */ + +.switch { position: relative; display: inline-flex; width: 38px; height: 21px; flex-shrink: 0; cursor: pointer; } +.switch input { + position: absolute; + inset: 0; + opacity: 0; + margin: 0; + cursor: pointer; +} +.switch-track { + position: absolute; + inset: 0; + border-radius: 999px; + background: var(--panel-strong); + border: 1px solid var(--border-strong); + transition: background 0.15s ease, border-color 0.15s ease; +} +.switch-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 15px; + height: 15px; + border-radius: 50%; + background: var(--muted); + transition: transform 0.15s ease, background 0.15s ease; +} +.switch input:checked ~ .switch-track { background: rgba(167, 139, 250, 0.22); border-color: var(--iris-violet); } +.switch input:checked ~ .switch-track .switch-thumb { + transform: translateX(17px); + background: var(--iris-violet); + box-shadow: 0 0 8px rgba(167, 139, 250, 0.6); +} +.switch input:focus-visible ~ .switch-track { outline: 2px solid var(--iris-cyan); outline-offset: 2px; } + /* ---- settings layout (sidebar sub-nav) ---- */ .settings-layout { diff --git a/app/templates/credentials/index.html b/app/templates/credentials/index.html index 35dd985..29a651d 100644 --- a/app/templates/credentials/index.html +++ b/app/templates/credentials/index.html @@ -6,9 +6,38 @@
{% for scope, fields in scope_fields.items() %} -
-

{{ scope }}

+ {% set is_core = scope in core_scopes %} + {% set is_enabled = enabled.get(scope, True) %} +
+
+
+

+ {{ scope }} + {% if is_core %} + Required + {% elif configured.get(scope) %} + Configured + {% else %} + Not configured + {% endif %} +

+

{{ scope_descriptions.get(scope, "") }}

+
+ {% if not is_core %} +
+ +
+ {% endif %} +
+ + {% if not is_core and not is_enabled %} +

Disabled — its scheduled automation is paused (or, for buy-link sources, its rendered credential file is removed) until you turn this back on. Saved values below are kept.

+ {% endif %} +
{% for field in fields %}
diff --git a/app/templates/playlists/detail.html b/app/templates/playlists/detail.html index f70af9d..503a998 100644 --- a/app/templates/playlists/detail.html +++ b/app/templates/playlists/detail.html @@ -57,19 +57,80 @@
{% set badge_class = {"IN_LIBRARY": "badge-success", "QUARANTINED": "badge-warning", "DOWNLOADED_PENDING_IMPORT": "badge-info", "NOT_YET_ATTEMPTED": "badge-muted"} %} -
- - - - {% for t in status.tracks %} - - - - - - {% endfor %} - -
ArtistTitleStatus
{{ t.artist }}{{ t.title }}{{ status_meta.get(t.status, (None, t.status))[1] }}
+ {% set dot_class = {"IN_LIBRARY": "status-dot-success", "QUARANTINED": "status-dot-warning", "DOWNLOADED_PENDING_IMPORT": "status-dot-info", "NOT_YET_ATTEMPTED": "status-dot-muted"} %} + {% set downloaded = status.tracks | rejectattr("status", "equalto", "NOT_YET_ATTEMPTED") | list %} + {% set waiting = status.tracks | selectattr("status", "equalto", "NOT_YET_ATTEMPTED") | list %} + +
+
+
+

Original playlist

+ {{ status.tracks | length }} track{{ "s" if status.tracks | length != 1 else "" }} +
+
+ + + + {% for t in status.tracks %} + + + + + + {% else %} + + {% endfor %} + +
ArtistTitle
{{ t.artist }}{{ t.title }}
No tracks found in this playlist.
+
+
+ +
+
+

By download status

+
+
+ + +
+ +
+
+ + + + {% for t in downloaded %} + + + + + + {% else %} + + {% endfor %} + +
ArtistTitleStatus
{{ t.artist }}{{ t.title }}{{ status_meta.get(t.status, (None, t.status))[1] }}
Nothing downloaded yet.
+
+
+ +
+
+ + + + {% for t in waiting %} + + + + + {% else %} + + {% endfor %} + +
ArtistTitle
{{ t.artist }}{{ t.title }}
Everything's been attempted at least once.
+
+
+
{% endif %} {% endblock %} diff --git a/app/templates/settings/_layout.html b/app/templates/settings/_layout.html index e7c8fb3..1a4e10b 100644 --- a/app/templates/settings/_layout.html +++ b/app/templates/settings/_layout.html @@ -4,7 +4,7 @@ @@ -12,6 +12,7 @@
{% block settings_content %}{% endblock %} diff --git a/app/templates/settings/artist_casing.html b/app/templates/settings/artist_casing.html new file mode 100644 index 0000000..311d7ff --- /dev/null +++ b/app/templates/settings/artist_casing.html @@ -0,0 +1,51 @@ +{% extends "settings/_layout.html" %} +{% block settings_title %}Artist Casing{% endblock %} +{% block settings_content %} +

Artist casing

+

+ Canonical spelling/casing for artist names, exactly as they're stylized on Spotify. + The Normalize artist casing job (Settings → Jobs) + rewrites any album artist tag that matches a name here case-insensitively but differs + in casing — e.g. MALL GRABMall Grab. + This prevents Linux and Windows from disagreeing about whether two folder names are + the same, which breaks Syncthing. +

+ +
+ +
+ + +
+ + +
+ +

Canonical list ({{ entries | length }})

+
+ + + + {% for name in entries | sort(case_sensitive=False) %} + + + + + {% else %} + + {% endfor %} + +
Artist
{{ name }} +
+ + +
+
+
+ +

No canonical names yet. Add one above.

+
+
+
+{% endblock %}