Credential enable/disable + descriptions, artist casing settings page, playlist compare view
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.
This commit is contained in:
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,9 +6,38 @@
|
||||
|
||||
<div class="cred-grid">
|
||||
{% for scope, fields in scope_fields.items() %}
|
||||
<div class="cred-scope">
|
||||
<h2 class="scope-header">{{ scope }}</h2>
|
||||
{% set is_core = scope in core_scopes %}
|
||||
{% set is_enabled = enabled.get(scope, True) %}
|
||||
<div class="cred-scope {{ 'cred-disabled' if not is_core and not is_enabled else '' }}">
|
||||
<div class="panel">
|
||||
<div class="cred-scope-header">
|
||||
<div>
|
||||
<h2>
|
||||
{{ scope }}
|
||||
{% if is_core %}
|
||||
<span class="badge badge-info" title="Needed for the core pipeline to work">Required</span>
|
||||
{% elif configured.get(scope) %}
|
||||
<span class="badge badge-success">Configured</span>
|
||||
{% else %}
|
||||
<span class="badge badge-muted">Not configured</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<p class="muted cred-desc">{{ scope_descriptions.get(scope, "") }}</p>
|
||||
</div>
|
||||
{% if not is_core %}
|
||||
<form method="post" action="/settings/credentials/{{ scope }}/toggle" class="inline">
|
||||
<label class="switch" title="{{ 'Disable' if is_enabled else 'Enable' }} {{ scope }}">
|
||||
<input type="checkbox" {{ "checked" if is_enabled else "" }} onchange="this.form.submit()">
|
||||
<span class="switch-track"><span class="switch-thumb"></span></span>
|
||||
</label>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not is_core and not is_enabled %}
|
||||
<p class="cred-disabled-note">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.</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/settings/credentials/{{ scope }}">
|
||||
{% for field in fields %}
|
||||
<div class="field wide">
|
||||
|
||||
@@ -57,19 +57,80 @@
|
||||
</div>
|
||||
|
||||
{% set badge_class = {"IN_LIBRARY": "badge-success", "QUARANTINED": "badge-warning", "DOWNLOADED_PENDING_IMPORT": "badge-info", "NOT_YET_ATTEMPTED": "badge-muted"} %}
|
||||
<div class="table-wrap scroll">
|
||||
<table>
|
||||
<thead><tr><th>Artist</th><th>Title</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in status.tracks %}
|
||||
<tr>
|
||||
<td>{{ t.artist }}</td>
|
||||
<td>{{ t.title }}</td>
|
||||
<td><span class="badge {{ badge_class.get(t.status, 'badge-muted') }}">{{ status_meta.get(t.status, (None, t.status))[1] }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% 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 %}
|
||||
|
||||
<div class="playlist-compare">
|
||||
<div class="compare-col">
|
||||
<div class="compare-col-header">
|
||||
<h3>Original playlist</h3>
|
||||
<span class="muted">{{ status.tracks | length }} track{{ "s" if status.tracks | length != 1 else "" }}</span>
|
||||
</div>
|
||||
<div class="table-wrap compare-scroll">
|
||||
<table>
|
||||
<thead><tr><th></th><th>Artist</th><th>Title</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in status.tracks %}
|
||||
<tr>
|
||||
<td><span class="status-dot {{ dot_class.get(t.status, 'status-dot-muted') }}" title="{{ status_meta.get(t.status, (None, t.status))[1] }}"></span></td>
|
||||
<td>{{ t.artist }}</td>
|
||||
<td>{{ t.title }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="3" class="muted">No tracks found in this playlist.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="compare-col" x-data="{ tab: 'downloaded' }" x-cloak>
|
||||
<div class="compare-col-header">
|
||||
<h3>By download status</h3>
|
||||
</div>
|
||||
<div class="seg-tabs">
|
||||
<button type="button" :class="{ active: tab === 'downloaded' }" @click="tab = 'downloaded'">Downloaded ({{ downloaded | length }})</button>
|
||||
<button type="button" :class="{ active: tab === 'waiting' }" @click="tab = 'waiting'">Waiting to download ({{ waiting | length }})</button>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'downloaded'">
|
||||
<div class="table-wrap compare-scroll">
|
||||
<table>
|
||||
<thead><tr><th>Artist</th><th>Title</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in downloaded %}
|
||||
<tr>
|
||||
<td>{{ t.artist }}</td>
|
||||
<td>{{ t.title }}</td>
|
||||
<td><span class="badge {{ badge_class.get(t.status, 'badge-muted') }}">{{ status_meta.get(t.status, (None, t.status))[1] }}</span></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="3" class="muted">Nothing downloaded yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'waiting'">
|
||||
<div class="table-wrap compare-scroll">
|
||||
<table>
|
||||
<thead><tr><th>Artist</th><th>Title</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in waiting %}
|
||||
<tr>
|
||||
<td>{{ t.artist }}</td>
|
||||
<td>{{ t.title }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="2" class="muted">Everything's been attempted at least once.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Settings</h1>
|
||||
<p class="subtitle muted">Credentials and background jobs.</p>
|
||||
<p class="subtitle muted">Credentials, background jobs, and library conventions.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<nav class="settings-nav">
|
||||
<a href="/settings/credentials" class="{{ 'active' if request.url.path.startswith('/settings/credentials') else '' }}">Credentials</a>
|
||||
<a href="/settings/jobs" class="{{ 'active' if request.url.path.startswith('/settings/jobs') else '' }}">Jobs</a>
|
||||
<a href="/settings/artist-casing" class="{{ 'active' if request.url.path.startswith('/settings/artist-casing') else '' }}">Artist Casing</a>
|
||||
</nav>
|
||||
<div class="settings-content">
|
||||
{% block settings_content %}{% endblock %}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends "settings/_layout.html" %}
|
||||
{% block settings_title %}Artist Casing{% endblock %}
|
||||
{% block settings_content %}
|
||||
<h2 style="margin-top:0;">Artist casing</h2>
|
||||
<p class="muted" style="margin-top:-0.75rem; max-width:60ch;">
|
||||
Canonical spelling/casing for artist names, exactly as they're stylized on Spotify.
|
||||
The <span class="mono">Normalize artist casing</span> job (Settings → <a href="/settings/jobs">Jobs</a>)
|
||||
rewrites any album artist tag that matches a name here case-insensitively but differs
|
||||
in casing — e.g. <span class="mono">MALL GRAB</span> → <span class="mono">Mall Grab</span>.
|
||||
This prevents Linux and Windows from disagreeing about whether two folder names are
|
||||
the same, which breaks Syncthing.
|
||||
</p>
|
||||
|
||||
<div class="panel" style="max-width:560px;">
|
||||
<form method="post" action="/settings/artist-casing/add" style="display:flex; gap:0.6rem; align-items:flex-end;">
|
||||
<div class="field wide" style="margin-bottom:0; flex:1;">
|
||||
<label for="name">Artist name</label>
|
||||
<input type="text" id="name" name="name" placeholder="exactly as Spotify displays it" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2>Canonical list <span class="muted" style="font-weight:400;">({{ entries | length }})</span></h2>
|
||||
<div class="table-wrap scroll">
|
||||
<table>
|
||||
<thead><tr><th>Artist</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for name in entries | sort(case_sensitive=False) %}
|
||||
<tr>
|
||||
<td>{{ name }}</td>
|
||||
<td class="actions-cell">
|
||||
<form method="post" action="/settings/artist-casing/remove" class="inline"
|
||||
onsubmit="return confirm('Remove {{ name }} from the canonical list?')">
|
||||
<input type="hidden" name="name" value="{{ name }}">
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="2">
|
||||
<div class="empty-state">
|
||||
<span class="sparkle" style="position:relative; display:inline-block; margin-bottom:0.5rem;"></span>
|
||||
<p class="muted" style="margin:0;">No canonical names yet. Add one above.</p>
|
||||
</div>
|
||||
</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user