Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5d9c7f043 | |||
| 91f3982eff | |||
| 97672ffdcd | |||
| 9a879b5322 |
@@ -1,6 +1,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ Before you begin, make sure you have:
|
|||||||
1. **A server that runs Docker**, with `docker compose` available. Any Linux box, NAS, or mini PC works.
|
1. **A server that runs Docker**, with `docker compose` available. Any Linux box, NAS, or mini PC works.
|
||||||
2. **A music server that reads a plain folder of tagged files**, such as [Navidrome](https://www.navidrome.org/). alembic builds your library on disk; Navidrome (or similar) is what you actually listen with.
|
2. **A music server that reads a plain folder of tagged files**, such as [Navidrome](https://www.navidrome.org/). alembic builds your library on disk; Navidrome (or similar) is what you actually listen with.
|
||||||
3. **A Soulseek account.** This is how tracks get downloaded. Sign up free at [soulseek.com](https://www.soulseek.com/).
|
3. **A Soulseek account.** This is how tracks get downloaded. Sign up free at [soulseek.com](https://www.soulseek.com/).
|
||||||
4. **A free Spotify Developer app.** This lets alembic read playlist contents (it does not download from Spotify itself, only Soulseek). Takes two minutes, see the credentials section below.
|
4. **A Spotify Developer app.** alembic reads your playlists' track lists from Spotify (it does not download from Spotify itself, only Soulseek). Create one free at [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard) for the Client ID and Secret. Because of a 2025/2026 Spotify API change, a brand-new app can no longer read playlists with just those two values — you also need to **connect your Spotify account** (one click in Settings → Credentials, standard Spotify login) the first time. This is a one-time step; see the credentials section below.
|
||||||
5. **A login provider that speaks OpenID Connect (OIDC).** alembic doesn't have its own username/password login, it delegates to something you already trust. [Pocket ID](https://github.com/pocket-id/pocket-id) is a small self-hosted option built exactly for this, but Authentik, Keycloak, Authelia, or any other OIDC provider works too.
|
5. **A login provider that speaks OpenID Connect (OIDC).** alembic doesn't have its own username/password login, it delegates to something you already trust. [Pocket ID](https://github.com/pocket-id/pocket-id) is a small self-hosted option built exactly for this, but Authentik, Keycloak, Authelia, or any other OIDC provider works too.
|
||||||
|
|
||||||
Optional, add these later if you want them:
|
Optional, add these later if you want them:
|
||||||
@@ -73,10 +73,10 @@ Optional, add these later if you want them:
|
|||||||
Pull the prebuilt image onto your Docker host:
|
Pull the prebuilt image onto your Docker host:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull git.kretzer.club/andrew/alembic:0.5
|
docker pull git.kretzer.club/andrew/alembic:0.6
|
||||||
```
|
```
|
||||||
|
|
||||||
That is the whole install. You do not need to download the source or build anything. The `0.5` is the version; you can pin to it so nothing changes under you, or use `latest` to always get the newest.
|
That is the whole install. You do not need to download the source or build anything. The `0.6` is the version; you can pin to it so nothing changes under you, or use `latest` to always get the newest.
|
||||||
|
|
||||||
(If you would rather build it yourself from source, you can, but you do not need to.)
|
(If you would rather build it yourself from source, you can, but you do not need to.)
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ Create a file called `docker-compose.yml` on your server (put it wherever you ke
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
alembic:
|
alembic:
|
||||||
image: git.kretzer.club/andrew/alembic:0.5
|
image: git.kretzer.club/andrew/alembic:0.6
|
||||||
container_name: alembic
|
container_name: alembic
|
||||||
ports:
|
ports:
|
||||||
- "8420:8420"
|
- "8420:8420"
|
||||||
@@ -191,14 +191,18 @@ Spotify, Soulseek, and Navidrome are marked **Required** — every playlist sync
|
|||||||
|
|
||||||
| Service | What to enter | Where to get it |
|
| Service | What to enter | Where to get it |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Spotify** *(required)* | Client ID, Client Secret | Create a free app at [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard). You don't need any special access, the basic free tier is enough to read playlist contents. |
|
| **Spotify** *(required)* | Client ID, Client Secret, then **Connect Spotify** | Reads your playlists' track lists. Create an app at [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard) for the Client ID/Secret, save them, then click **Connect Spotify** and log in with your Spotify account — required for any app created after Spotify's 2025/2026 API change (see Troubleshooting below). |
|
||||||
| **Soulseek** *(required)* | Username, Password | Your normal Soulseek login. |
|
| **Soulseek** *(required)* | Username, Password | Your normal Soulseek login. |
|
||||||
| **Navidrome** *(required)* | Base URL, admin username, admin password | The address of your Navidrome server and an admin account on it. Used so alembic can trigger a library rescan after downloads. |
|
| **Navidrome** *(required)* | Base URL, admin username, admin password | The address of your Navidrome server and an admin account on it. Used so alembic can trigger a library rescan after downloads. |
|
||||||
| **Bandcamp** *(optional)* | Username, format preference, cookies | Export your Bandcamp cookies while logged into bandcamp.com in your browser, using an extension like "Get cookies.txt LOCALLY", and paste the contents in. Format preference is a space-separated list like `flac mp3-320`. |
|
| **Bandcamp** *(optional)* | Username, format preference, cookies | Export your Bandcamp cookies while logged into bandcamp.com in your browser, using an extension like "Get cookies.txt LOCALLY", and paste the contents in. Format preference is a space-separated list like `flac mp3-320`. |
|
||||||
| **Qobuz** *(optional)* | Token, App ID, region | A logged-in Qobuz session token — used (alongside Bandcamp) as a source for "buy this" links stamped onto tracks you didn't purchase there. |
|
| **Qobuz** *(optional)* | Token, App ID, region | A logged-in Qobuz session token — used (alongside Bandcamp) as a source for "buy this" links stamped onto tracks you didn't purchase there. |
|
||||||
| **AzuraCast** *(optional)* | API key | Only relevant if you also run an AzuraCast radio station off the same library. It's not a buy-link source itself (that's Bandcamp/Qobuz, written straight to the file); this just lets alembic tell AzuraCast to immediately pick up a tag change instead of waiting for its own periodic scan. |
|
| **AzuraCast** *(optional)* | Base URL, API key | Only relevant if you also run an AzuraCast radio station off the same library. It's not a buy-link source itself (that's Bandcamp/Qobuz, written straight to the file); this just lets alembic tell AzuraCast to immediately pick up a tag change instead of waiting for its own periodic scan. Base URL is your AzuraCast instance's address, e.g. `https://radio.example.com`. |
|
||||||
| **Telegram** *(optional)* | Bot token, chat ID | Create a bot via [@BotFather](https://t.me/BotFather) if you want the daily status digest sent to a chat. |
|
| **Telegram** *(optional)* | Bot token, chat ID | Create a bot via [@BotFather](https://t.me/BotFather) if you want the daily status digest sent to a chat. |
|
||||||
|
|
||||||
|
### Connecting your Spotify account
|
||||||
|
|
||||||
|
After saving your Spotify Client ID and Secret, add this redirect URI in your Spotify app's dashboard settings (Developer Dashboard → your app → Settings → Redirect URIs): `https://<your-host>/connect/spotify/callback`. Then click **Connect Spotify** on the Credentials page and log in — this only needs to be done once. It's what lets alembic read playlists on an app created after Spotify's 2025/2026 restriction on app-only playlist access; grandfathered apps continue to work without it, but connecting is harmless either way.
|
||||||
|
|
||||||
## Adding your first playlist
|
## Adding your first playlist
|
||||||
|
|
||||||
1. Go to **Playlists** → **Add playlist**.
|
1. Go to **Playlists** → **Add playlist**.
|
||||||
@@ -220,7 +224,7 @@ That's it. On its next scheduled run (or immediately, using the **Run now** butt
|
|||||||
|
|
||||||
## Updating alembic
|
## Updating alembic
|
||||||
|
|
||||||
When a new version is released, change the version in your `docker-compose.yml` (for example `:0.5` to the new number), then pull and restart:
|
When a new version is released, change the version in your `docker-compose.yml` (for example `:0.6` to the new number), then pull and restart:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose pull
|
docker compose pull
|
||||||
@@ -269,6 +273,11 @@ Double check the redirect URI registered with your OIDC provider exactly matches
|
|||||||
**A playlist says it's synced but nothing downloaded.**
|
**A playlist says it's synced but nothing downloaded.**
|
||||||
Check that playlist's job history under **Settings → Jobs**, the log will usually show whether Soulseek couldn't find a track, or a credential is missing. The playlist's own page will also show everything as "Waiting to download" if nothing came through.
|
Check that playlist's job history under **Settings → Jobs**, the log will usually show whether Soulseek couldn't find a track, or a credential is missing. The playlist's own page will also show everything as "Waiting to download" if nothing came through.
|
||||||
|
|
||||||
|
**The playlist page shows "Spotify denied access (403)" or a 401, or nothing downloads and the logs mention one of those.**
|
||||||
|
Your Spotify app can't read the playlist with app-only (client-credentials) access alone. Spotify closed that off for apps created after its 2025/2026 API change, on both the old `/tracks` endpoint (403) and the current `/items` endpoint (401 "valid user authentication required") — this affects even public playlists. The "extended quota mode" that would lift it is granted only to organizations, not individuals, since May 2025.
|
||||||
|
|
||||||
|
The fix: go to **Settings → Credentials** and click **Connect Spotify** (see "Connecting your Spotify account" above). This mints a user token that works regardless of when your app was created. If you haven't already, add the redirect URI `https://<your-host>/connect/spotify/callback` in your Spotify app's dashboard first, or the connect step itself will fail.
|
||||||
|
|
||||||
**Dedup found something that isn't actually a duplicate.**
|
**Dedup found something that isn't actually a duplicate.**
|
||||||
Use the "Keep both" button on that pair. alembic will remember your decision and won't flag that exact pair again.
|
Use the "Keep both" button on that pair. alembic will remember your decision and won't flag that exact pair again.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
|||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from app.db import enable_beets_db_wal, init_db, mark_interrupted_runs
|
from app.db import enable_beets_db_wal, init_db, mark_interrupted_runs
|
||||||
from app.routers import artist_casing, auth, credentials, dashboard, dedup, genres, health, import_, jobs, library, playlists
|
from app.routers import artist_casing, auth, connect, credentials, dashboard, dedup, genres, health, import_, jobs, library, playlists
|
||||||
from app.security.session import resolve_session_secret
|
from app.security.session import resolve_session_secret
|
||||||
from app.services import scheduler_service
|
from app.services import scheduler_service
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
@@ -82,6 +82,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(genres.router)
|
app.include_router(genres.router)
|
||||||
app.include_router(playlists.router)
|
app.include_router(playlists.router)
|
||||||
app.include_router(credentials.router)
|
app.include_router(credentials.router)
|
||||||
|
app.include_router(connect.router)
|
||||||
app.include_router(jobs.router)
|
app.include_router(jobs.router)
|
||||||
app.include_router(artist_casing.router)
|
app.include_router(artist_casing.router)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Spotify OAuth "Connect Spotify" flow.
|
||||||
|
|
||||||
|
Not on the /auth prefix or the /settings/credentials prefix: Spotify's app
|
||||||
|
dashboard needs one fixed redirect URI to register, and this is it in full:
|
||||||
|
https://<host>/connect/spotify/callback.
|
||||||
|
|
||||||
|
Unlike Pocket ID (app/security/oidc.py), Spotify's client id/secret live in
|
||||||
|
the encrypted credential store, not app.settings -- so this builds the
|
||||||
|
authorize/token requests directly with httpx instead of going through an
|
||||||
|
authlib client registered once at import time with fixed credentials.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import secrets
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
from app.db import get_db
|
||||||
|
from app.security.deps import require_auth
|
||||||
|
from app.services import credential_service
|
||||||
|
|
||||||
|
router = APIRouter(tags=["spotify-connect"])
|
||||||
|
|
||||||
|
AUTHORIZE_URL = "https://accounts.spotify.com/authorize"
|
||||||
|
TOKEN_URL = "https://accounts.spotify.com/api/token"
|
||||||
|
SCOPES = "playlist-read-private playlist-read-collaborative"
|
||||||
|
|
||||||
|
|
||||||
|
def _callback_redirect_uri(request: Request) -> str:
|
||||||
|
"""request.url_for() reflects the scheme uvicorn saw on the actual TCP
|
||||||
|
connection, not what the reverse proxy served over -- alembic sits behind
|
||||||
|
a TLS-terminating proxy (per every deployment example in the README) and
|
||||||
|
uvicorn isn't told to trust its X-Forwarded-Proto, so this comes back
|
||||||
|
`http://`. Spotify requires an exact scheme match against the redirect
|
||||||
|
URI registered in its app dashboard (always https for a real hostname),
|
||||||
|
so force it here rather than trust the raw connection's scheme."""
|
||||||
|
return str(request.url_for("spotify_callback").replace(scheme="https"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/connect/spotify")
|
||||||
|
async def connect_spotify(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
|
||||||
|
client_id = credential_service.get_scope(db, "spotify").get("client_id")
|
||||||
|
if not client_id:
|
||||||
|
# Nothing to connect to yet -- send them to save client_id/secret first.
|
||||||
|
return RedirectResponse(url="/settings/credentials", status_code=303)
|
||||||
|
|
||||||
|
# CSRF guard on the callback: the state we get back must match what we
|
||||||
|
# handed out for this session, not just be present.
|
||||||
|
state = secrets.token_urlsafe(24)
|
||||||
|
request.session["spotify_oauth_state"] = state
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"client_id": client_id,
|
||||||
|
"response_type": "code",
|
||||||
|
"redirect_uri": _callback_redirect_uri(request),
|
||||||
|
"scope": SCOPES,
|
||||||
|
"state": state,
|
||||||
|
}
|
||||||
|
return RedirectResponse(url=f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/connect/spotify/callback", name="spotify_callback")
|
||||||
|
async def spotify_callback(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
|
||||||
|
if error := request.query_params.get("error"):
|
||||||
|
raise HTTPException(400, f"Spotify authorization failed: {error}")
|
||||||
|
|
||||||
|
state = request.query_params.get("state")
|
||||||
|
if not state or state != request.session.pop("spotify_oauth_state", None):
|
||||||
|
raise HTTPException(400, "Spotify authorization state mismatch")
|
||||||
|
|
||||||
|
code = request.query_params.get("code")
|
||||||
|
if not code:
|
||||||
|
raise HTTPException(400, "Spotify did not return an authorization code")
|
||||||
|
|
||||||
|
creds = credential_service.get_scope(db, "spotify")
|
||||||
|
client_id = creds.get("client_id")
|
||||||
|
client_secret = creds.get("client_secret")
|
||||||
|
if not client_id or not client_secret:
|
||||||
|
raise HTTPException(400, "Spotify client id/secret not configured")
|
||||||
|
|
||||||
|
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
||||||
|
resp = httpx.post(
|
||||||
|
TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": _callback_redirect_uri(request),
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Basic {basic}"},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
refresh_token = resp.json().get("refresh_token")
|
||||||
|
if not refresh_token:
|
||||||
|
raise HTTPException(400, "Spotify did not return a refresh token")
|
||||||
|
|
||||||
|
credential_service.set_credentials(db, "spotify", {"refresh_token": refresh_token})
|
||||||
|
return RedirectResponse(url="/settings/credentials", status_code=303)
|
||||||
+19
-4
@@ -6,7 +6,7 @@ from sqlalchemy import select
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import JobRun, ScheduledJob
|
from app.models import JobRun, ScheduledJob
|
||||||
from app.security.deps import require_auth
|
from app.security.deps import require_auth
|
||||||
from app.services import scheduler_service
|
from app.services import pipeline_runner, playlist_service, scheduler_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/settings/jobs", tags=["jobs"])
|
router = APIRouter(prefix="/settings/jobs", tags=["jobs"])
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
@@ -67,7 +67,9 @@ async def runs_table_partial(request: Request, user: dict = Depends(require_auth
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{job_key:path}/run")
|
@router.post("/{job_key:path}/run")
|
||||||
async def run_now(job_key: str, background: BackgroundTasks, user: dict = Depends(require_auth)):
|
async def run_now(
|
||||||
|
job_key: str, background: BackgroundTasks, user: dict = Depends(require_auth), db=Depends(get_db)
|
||||||
|
):
|
||||||
scheduler = scheduler_service.get_scheduler()
|
scheduler = scheduler_service.get_scheduler()
|
||||||
if scheduler is None:
|
if scheduler is None:
|
||||||
raise HTTPException(503, "scheduler not running")
|
raise HTTPException(503, "scheduler not running")
|
||||||
@@ -75,11 +77,24 @@ async def run_now(job_key: str, background: BackgroundTasks, user: dict = Depend
|
|||||||
# background and redirect immediately. A playlist sync can take the better
|
# background and redirect immediately. A playlist sync can take the better
|
||||||
# part of an hour; awaiting it here would hang the browser/reverse proxy.
|
# part of an hour; awaiting it here would hang the browser/reverse proxy.
|
||||||
# Progress shows up in the runs table below, which polls every few seconds.
|
# Progress shows up in the runs table below, which polls every few seconds.
|
||||||
if scheduler.get_job(job_key) is None:
|
if scheduler.get_job(job_key) is not None:
|
||||||
raise HTTPException(404, f"no such registered job: {job_key}")
|
|
||||||
background.add_task(scheduler_service.trigger_now, scheduler, job_key)
|
background.add_task(scheduler_service.trigger_now, scheduler, job_key)
|
||||||
return RedirectResponse(url="/settings/jobs?started=1", status_code=303)
|
return RedirectResponse(url="/settings/jobs?started=1", status_code=303)
|
||||||
|
|
||||||
|
# Playlists with no cron_expr (or paused) are never registered with the
|
||||||
|
# scheduler by sync_playlist_jobs -- that's "unscheduled/manual-only" by
|
||||||
|
# design, not missing. Run it directly through pipeline_runner instead of
|
||||||
|
# requiring a scheduler job to exist, so "Run now" works for those too.
|
||||||
|
if job_key.startswith("playlist:"):
|
||||||
|
playlist = playlist_service.get_by_name(db, job_key.removeprefix("playlist:"))
|
||||||
|
if playlist is not None:
|
||||||
|
background.add_task(
|
||||||
|
pipeline_runner.run_playlist, playlist.name, playlist.no_m3u, "manual"
|
||||||
|
)
|
||||||
|
return RedirectResponse(url="/settings/jobs?started=1", status_code=303)
|
||||||
|
|
||||||
|
raise HTTPException(404, f"no such registered job: {job_key}")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{job_key:path}/toggle")
|
@router.post("/{job_key:path}/toggle")
|
||||||
async def toggle_enabled(job_key: str, user: dict = Depends(require_auth), db=Depends(get_db)):
|
async def toggle_enabled(job_key: str, user: dict = Depends(require_auth), db=Depends(get_db)):
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@@ -10,6 +11,19 @@ router = APIRouter(prefix="/playlists", tags=["playlists"])
|
|||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
|
|
||||||
|
def _friendly_status_error(exc: Exception) -> str:
|
||||||
|
"""Turn a raw Spotify API error into something a non-technical user can act
|
||||||
|
on. 403 here almost always means the Spotify app can't read the playlist:
|
||||||
|
either the credentials are wrong or the playlist isn't public."""
|
||||||
|
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 403:
|
||||||
|
return (
|
||||||
|
"Spotify denied access (403). Check your Spotify credentials under "
|
||||||
|
"Settings then Credentials, and make sure the playlist is set to public "
|
||||||
|
"on Spotify -- alembic can't read private playlists."
|
||||||
|
)
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def playlists_index(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
|
async def playlists_index(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
|
||||||
playlists = playlist_service.list_all(db)
|
playlists = playlist_service.list_all(db)
|
||||||
@@ -54,7 +68,7 @@ async def playlist_detail(
|
|||||||
try:
|
try:
|
||||||
status = status_service.playlist_status(db, playlist.name, playlist.spotify_url)
|
status = status_service.playlist_status(db, playlist.name, playlist.spotify_url)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
error = str(exc)
|
error = _friendly_status_error(exc)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ from app.settings import settings
|
|||||||
# Which fields exist per scope, and whether each is safe to display back to
|
# 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).
|
# the UI once saved (short opaque strings need never be shown again).
|
||||||
SCOPE_FIELDS = {
|
SCOPE_FIELDS = {
|
||||||
"spotify": ["client_id", "client_secret"],
|
"spotify": ["client_id", "client_secret", "refresh_token"],
|
||||||
"soulseek": ["username", "password"],
|
"soulseek": ["username", "password"],
|
||||||
"navidrome": ["base_url", "admin_user", "admin_pass"],
|
"navidrome": ["base_url", "admin_user", "admin_pass"],
|
||||||
"bandcamp": ["username", "format_pref", "cookies_txt"],
|
"bandcamp": ["username", "format_pref", "cookies_txt"],
|
||||||
"azuracast": ["api_key"],
|
"azuracast": ["base_url", "api_key"],
|
||||||
"qobuz": ["token", "app_id", "region"],
|
"qobuz": ["token", "app_id", "region"],
|
||||||
"telegram": ["bot_token", "chat_id"],
|
"telegram": ["bot_token", "chat_id"],
|
||||||
}
|
}
|
||||||
@@ -56,6 +56,7 @@ SECRET_KEYS = {
|
|||||||
"api_key",
|
"api_key",
|
||||||
"token",
|
"token",
|
||||||
"bot_token",
|
"bot_token",
|
||||||
|
"refresh_token",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -116,7 +117,7 @@ _SAFE_CONF_NAME = re.compile(r"^[A-Za-z0-9 _-]{1,64}$")
|
|||||||
|
|
||||||
# The credential lines the renderer fills in from the encrypted store. Everything
|
# The credential lines the renderer fills in from the encrypted store. Everything
|
||||||
# else in a rendered .conf comes verbatim from _template.conf.
|
# else in a rendered .conf comes verbatim from _template.conf.
|
||||||
_CONF_CRED_KEYS = ("user", "pass", "spotify-id", "spotify-secret")
|
_CONF_CRED_KEYS = ("user", "pass", "spotify-id", "spotify-secret", "spotify-refresh")
|
||||||
|
|
||||||
|
|
||||||
def _set_conf_field(text: str, key: str, value: str) -> str:
|
def _set_conf_field(text: str, key: str, value: str) -> str:
|
||||||
@@ -153,6 +154,7 @@ def render_playlist_confs(db: Session) -> None:
|
|||||||
"pass": soulseek.get("password", ""),
|
"pass": soulseek.get("password", ""),
|
||||||
"spotify-id": spotify.get("client_id", ""),
|
"spotify-id": spotify.get("client_id", ""),
|
||||||
"spotify-secret": spotify.get("client_secret", ""),
|
"spotify-secret": spotify.get("client_secret", ""),
|
||||||
|
"spotify-refresh": spotify.get("refresh_token", ""),
|
||||||
}
|
}
|
||||||
# Path placeholders are substituted as LITERAL text in a single left-to-right
|
# Path placeholders are substituted as LITERAL text in a single left-to-right
|
||||||
# pass (never re-scanned), so a value can't be reinterpreted as another
|
# pass (never re-scanned), so a value can't be reinterpreted as another
|
||||||
@@ -191,10 +193,20 @@ def render_scope(db: Session, scope: str) -> None:
|
|||||||
if scope == "spotify":
|
if scope == "spotify":
|
||||||
cid = values.get("client_id", "")
|
cid = values.get("client_id", "")
|
||||||
csec = values.get("client_secret", "")
|
csec = values.get("client_secret", "")
|
||||||
# _spotify.env is read by the pipeline python scripts (spotify-genre etc.).
|
# _spotify.env is read by the pipeline python scripts (spotify-genre,
|
||||||
|
# spotify-retag, fix-track-metadata). SPOTIFY_REFRESH_TOKEN is only set
|
||||||
|
# once an account is connected via /connect/spotify -- its presence is
|
||||||
|
# what switches every reader from client-credentials to a user token
|
||||||
|
# (see _spotify_auth.get_token). The rendered playlist .conf gets the
|
||||||
|
# same refresh token too (spotify-refresh, in render_playlist_confs),
|
||||||
|
# which is what lets sldl itself use a user token.
|
||||||
_write_env_file(
|
_write_env_file(
|
||||||
settings.pipeline_config_dir / "_spotify.env",
|
settings.pipeline_config_dir / "_spotify.env",
|
||||||
{"SPOTIFY_CLIENT_ID": cid, "SPOTIFY_CLIENT_SECRET": csec},
|
{
|
||||||
|
"SPOTIFY_CLIENT_ID": cid,
|
||||||
|
"SPOTIFY_CLIENT_SECRET": csec,
|
||||||
|
"SPOTIFY_REFRESH_TOKEN": values.get("refresh_token", ""),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
# Re-render every playlist .conf so the new Spotify creds land in them.
|
# Re-render every playlist .conf so the new Spotify creds land in them.
|
||||||
render_playlist_confs(db)
|
render_playlist_confs(db)
|
||||||
@@ -239,6 +251,10 @@ def render_scope(db: Session, scope: str) -> None:
|
|||||||
key_path = az_dir / "api_key"
|
key_path = az_dir / "api_key"
|
||||||
key_path.write_text(values.get("api_key", ""))
|
key_path.write_text(values.get("api_key", ""))
|
||||||
key_path.chmod(0o600)
|
key_path.chmod(0o600)
|
||||||
|
# base_url isn't secret, but still keyfile-based (not env) so it's
|
||||||
|
# readable via the same fallback cascade as api_key -- see
|
||||||
|
# enrich-buy-url.py's --azuracast-base resolution.
|
||||||
|
(az_dir / "base_url").write_text(values.get("base_url", ""))
|
||||||
|
|
||||||
elif scope == "qobuz":
|
elif scope == "qobuz":
|
||||||
qobuz_dir = settings.pipeline_config_dir / "qobuz"
|
qobuz_dir = settings.pipeline_config_dir / "qobuz"
|
||||||
@@ -289,7 +305,9 @@ def _clear_rendered(scope: str) -> None:
|
|||||||
notify-telegram.sh call). Re-enabling calls render_scope() again to
|
notify-telegram.sh call). Re-enabling calls render_scope() again to
|
||||||
recreate the file from the still-stored values."""
|
recreate the file from the still-stored values."""
|
||||||
if scope == "azuracast":
|
if scope == "azuracast":
|
||||||
(settings.pipeline_config_dir / "azuracast" / "api_key").unlink(missing_ok=True)
|
az_dir = settings.pipeline_config_dir / "azuracast"
|
||||||
|
(az_dir / "api_key").unlink(missing_ok=True)
|
||||||
|
(az_dir / "base_url").unlink(missing_ok=True)
|
||||||
elif scope == "qobuz":
|
elif scope == "qobuz":
|
||||||
qobuz_dir = settings.pipeline_config_dir / "qobuz"
|
qobuz_dir = settings.pipeline_config_dir / "qobuz"
|
||||||
for name in ("token", "app_id", "region"):
|
for name in ("token", "app_id", "region"):
|
||||||
@@ -360,6 +378,8 @@ def auth_states(db: Session) -> list[dict]:
|
|||||||
values = get_scope(db, scope)
|
values = get_scope(db, scope)
|
||||||
configured = bool(values)
|
configured = bool(values)
|
||||||
entry = {"scope": scope, "configured": configured}
|
entry = {"scope": scope, "configured": configured}
|
||||||
|
if scope == "spotify":
|
||||||
|
entry["spotify_connected"] = bool(values.get("refresh_token"))
|
||||||
if scope == "bandcamp" and values.get("cookies_txt"):
|
if scope == "bandcamp" and values.get("cookies_txt"):
|
||||||
expiry = _bandcamp_cookie_expiry(values["cookies_txt"])
|
expiry = _bandcamp_cookie_expiry(values["cookies_txt"])
|
||||||
if expiry is not None:
|
if expiry is not None:
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ TOKEN_URL = "https://accounts.spotify.com/api/token"
|
|||||||
API_BASE = "https://api.spotify.com/v1"
|
API_BASE = "https://api.spotify.com/v1"
|
||||||
|
|
||||||
# Module-level cache: one alembic process, one Spotify app registration --
|
# Module-level cache: one alembic process, one Spotify app registration --
|
||||||
# a single shared client-credentials token is fine (no per-user tokens here).
|
# a single shared token is fine (single-user app, no per-request identity).
|
||||||
_token_cache: dict = {"token": None, "expires_at": 0.0}
|
# Keyed by mode too, so connecting/disconnecting an account (switching
|
||||||
|
# between a user token and a client-credentials token) can't serve a stale
|
||||||
|
# token minted under the other grant type.
|
||||||
|
_token_cache: dict = {"token": None, "expires_at": 0.0, "mode": None}
|
||||||
|
|
||||||
_PLAYLIST_ID_RE = re.compile(r"playlist/([A-Za-z0-9]+)")
|
_PLAYLIST_ID_RE = re.compile(r"playlist/([A-Za-z0-9]+)")
|
||||||
|
|
||||||
@@ -26,19 +29,34 @@ def _extract_playlist_id(playlist_url: str) -> str:
|
|||||||
|
|
||||||
def _get_token(db: Session) -> str:
|
def _get_token(db: Session) -> str:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if _token_cache["token"] and _token_cache["expires_at"] > now + 30:
|
|
||||||
return _token_cache["token"]
|
|
||||||
|
|
||||||
creds = credential_service.get_scope(db, "spotify")
|
creds = credential_service.get_scope(db, "spotify")
|
||||||
client_id = creds.get("client_id")
|
client_id = creds.get("client_id")
|
||||||
client_secret = creds.get("client_secret")
|
client_secret = creds.get("client_secret")
|
||||||
if not client_id or not client_secret:
|
if not client_id or not client_secret:
|
||||||
raise RuntimeError("Spotify credentials not configured (settings/credentials)")
|
raise RuntimeError("Spotify credentials not configured (settings/credentials)")
|
||||||
|
refresh_token = creds.get("refresh_token")
|
||||||
|
# A user token (from /connect/spotify) can read playlists on newly created
|
||||||
|
# Spotify apps; client-credentials can't (Spotify blocks GET /items for
|
||||||
|
# new apps without user auth). Prefer the user token whenever one is
|
||||||
|
# connected, falling back to client-credentials for grandfathered apps.
|
||||||
|
mode = "user" if refresh_token else "client_credentials"
|
||||||
|
|
||||||
|
if (
|
||||||
|
_token_cache["token"]
|
||||||
|
and _token_cache["mode"] == mode
|
||||||
|
and _token_cache["expires_at"] > now + 30
|
||||||
|
):
|
||||||
|
return _token_cache["token"]
|
||||||
|
|
||||||
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
||||||
|
grant_data = (
|
||||||
|
{"grant_type": "refresh_token", "refresh_token": refresh_token}
|
||||||
|
if refresh_token
|
||||||
|
else {"grant_type": "client_credentials"}
|
||||||
|
)
|
||||||
resp = httpx.post(
|
resp = httpx.post(
|
||||||
TOKEN_URL,
|
TOKEN_URL,
|
||||||
data={"grant_type": "client_credentials"},
|
data=grant_data,
|
||||||
headers={"Authorization": f"Basic {basic}"},
|
headers={"Authorization": f"Basic {basic}"},
|
||||||
timeout=15,
|
timeout=15,
|
||||||
)
|
)
|
||||||
@@ -46,6 +64,7 @@ def _get_token(db: Session) -> str:
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
_token_cache["token"] = data["access_token"]
|
_token_cache["token"] = data["access_token"]
|
||||||
_token_cache["expires_at"] = now + data["expires_in"]
|
_token_cache["expires_at"] = now + data["expires_in"]
|
||||||
|
_token_cache["mode"] = mode
|
||||||
return _token_cache["token"]
|
return _token_cache["token"]
|
||||||
|
|
||||||
|
|
||||||
@@ -57,7 +76,11 @@ def get_playlist_tracks(db: Session, playlist_url: str) -> list[dict]:
|
|||||||
headers = {"Authorization": f"Bearer {token}"}
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
tracks = []
|
tracks = []
|
||||||
url = f"{API_BASE}/playlists/{playlist_id}/tracks"
|
# /items, not /tracks: Spotify removed GET /playlists/{id}/tracks in its
|
||||||
|
# February 2026 API changes in favor of /items (same response shape). New
|
||||||
|
# apps are 403'd on the old endpoint; grandfathered apps still tolerate it
|
||||||
|
# for now, but /items is the correct, future-proof one.
|
||||||
|
url = f"{API_BASE}/playlists/{playlist_id}/items"
|
||||||
params = {"limit": 100, "fields": "items(track(name,artists(name),external_ids)),next"}
|
params = {"limit": 100, "fields": "items(track(name,artists(name),external_ids)),next"}
|
||||||
|
|
||||||
while url:
|
while url:
|
||||||
|
|||||||
@@ -45,8 +45,22 @@
|
|||||||
<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>
|
<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 %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if scope == "spotify" %}
|
||||||
|
<div class="field wide" style="margin-bottom:1rem;">
|
||||||
|
<label>Account connection</label>
|
||||||
|
<p class="muted" style="margin:0 0 0.5rem;">Newly created Spotify apps can no longer read playlists without this — connecting an account mints a user token that works where the app-only credentials above are blocked. Grandfathered apps keep working without it.</p>
|
||||||
|
{% if "refresh_token" in configured.get(scope, []) %}
|
||||||
|
<span class="badge badge-success">Connected</span>
|
||||||
|
<a href="/connect/spotify" class="btn" style="margin-left:0.5rem;">Reconnect Spotify</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="/connect/spotify" class="btn">Connect Spotify</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="/settings/credentials/{{ scope }}">
|
<form method="post" action="/settings/credentials/{{ scope }}">
|
||||||
{% for field in fields %}
|
{% for field in fields %}
|
||||||
|
{% if not (scope == "spotify" and field == "refresh_token") %}
|
||||||
<div class="field wide">
|
<div class="field wide">
|
||||||
<label for="{{ scope }}_{{ field }}">
|
<label for="{{ scope }}_{{ field }}">
|
||||||
{{ field }}
|
{{ field }}
|
||||||
@@ -60,6 +74,7 @@
|
|||||||
<input type="text" id="{{ scope }}_{{ field }}" name="{{ field }}" placeholder="leave blank to keep current" autocomplete="off" spellcheck="false">
|
<input type="text" id="{{ scope }}_{{ field }}" name="{{ field }}" placeholder="leave blank to keep current" autocomplete="off" spellcheck="false">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<button type="submit" class="btn">Save {{ scope }}</button>
|
<button type="submit" class="btn">Save {{ scope }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
<h2>Credential status</h2>
|
<h2>Credential status</h2>
|
||||||
<ul class="chip-list auth-chip-list">
|
<ul class="chip-list auth-chip-list">
|
||||||
{% for a in auth_states %}
|
{% for a in auth_states %}
|
||||||
<li title="{{ a.scope }}: {{ 'configured' if a.configured else 'not configured' }}{% if a.days_left is defined %}, cookie expires in {{ a.days_left }}d{% endif %}"><span class="status-dot {{ 'status-dot-success' if a.configured else 'status-dot-muted' }}{% if a.days_left is defined and a.days_left < 14 %} status-dot-warning{% endif %}"></span>{{ a.scope }}{% if a.days_left is defined %}<span class="muted"> {{ a.days_left }}d</span>{% endif %}</li>
|
<li title="{{ a.scope }}: {{ 'configured' if a.configured else 'not configured' }}{% if a.days_left is defined %}, cookie expires in {{ a.days_left }}d{% endif %}{% if a.spotify_connected is defined %}, {{ 'OAuth connected' if a.spotify_connected else 'app-only (client-credentials)' }}{% endif %}"><span class="status-dot {{ 'status-dot-success' if a.configured else 'status-dot-muted' }}{% if a.days_left is defined and a.days_left < 14 %} status-dot-warning{% endif %}"></span>{{ a.scope }}{% if a.days_left is defined %}<span class="muted"> {{ a.days_left }}d</span>{% endif %}{% if a.spotify_connected %}<span class="muted"> (OAuth)</span>{% endif %}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
<p><a href="/settings/credentials">Manage credentials →</a></p>
|
<p><a href="/settings/credentials">Manage credentials →</a></p>
|
||||||
|
|||||||
@@ -65,7 +65,11 @@ log "Running sldl for: $PLAYLIST_NAME"
|
|||||||
# which silently skips every later-scheduled playlist for the night. (Seen
|
# which silently skips every later-scheduled playlist for the night. (Seen
|
||||||
# 2026-06-18: a transient Spotify client-creds failure made sldl fall back to
|
# 2026-06-18: a transient Spotify client-creds failure made sldl fall back to
|
||||||
# interactive OAuth — "manually open: https://accounts.spotify.com/..." — and
|
# interactive OAuth — "manually open: https://accounts.spotify.com/..." — and
|
||||||
# it waited 7h on a browser callback that never comes.) timeout TERMs the run
|
# it waited 7h on a browser callback that never comes. Fixed by always
|
||||||
|
# rendering `spotify-refresh` into the conf once a Spotify account is
|
||||||
|
# connected via /connect/spotify — sldl's own docs confirm supplying a
|
||||||
|
# refresh token skips the login-flow requirement entirely. This timeout stays
|
||||||
|
# as a backstop for any other cause of a stuck run.) timeout TERMs the run
|
||||||
# and KILLs after a grace period; there's no leftover container to clean up
|
# and KILLs after a grace period; there's no leftover container to clean up
|
||||||
# now that sldl is a plain subprocess instead of a docker-compose service.
|
# now that sldl is a plain subprocess instead of a docker-compose service.
|
||||||
SLDL_TIMEOUT="${SLDL_TIMEOUT:-2700}" # 45 min; override via env
|
SLDL_TIMEOUT="${SLDL_TIMEOUT:-2700}" # 45 min; override via env
|
||||||
@@ -115,10 +119,12 @@ find "$DROPBOX" -name "*.incomplete" -type f -delete 2>>"$LOG" || true
|
|||||||
SPOTIFY_URL=$(sed -n 's/^input *= *//p' "$CONFIG_FILE" | tr -d ' ')
|
SPOTIFY_URL=$(sed -n 's/^input *= *//p' "$CONFIG_FILE" | tr -d ' ')
|
||||||
SPOTIFY_CLIENT_ID=$(sed -n 's/^spotify-id *= *//p' "$CONFIG_FILE" | tr -d ' ')
|
SPOTIFY_CLIENT_ID=$(sed -n 's/^spotify-id *= *//p' "$CONFIG_FILE" | tr -d ' ')
|
||||||
SPOTIFY_CLIENT_SECRET=$(sed -n 's/^spotify-secret *= *//p' "$CONFIG_FILE" | tr -d ' ')
|
SPOTIFY_CLIENT_SECRET=$(sed -n 's/^spotify-secret *= *//p' "$CONFIG_FILE" | tr -d ' ')
|
||||||
|
SPOTIFY_REFRESH_TOKEN=$(sed -n 's/^spotify-refresh *= *//p' "$CONFIG_FILE" | tr -d ' ')
|
||||||
|
|
||||||
if [[ -n "$SPOTIFY_URL" && -n "$SPOTIFY_CLIENT_ID" && -n "$SPOTIFY_CLIENT_SECRET" ]]; then
|
if [[ -n "$SPOTIFY_URL" && -n "$SPOTIFY_CLIENT_ID" && -n "$SPOTIFY_CLIENT_SECRET" ]]; then
|
||||||
log "Rewriting tags from Spotify for files in $DROPBOX"
|
log "Rewriting tags from Spotify for files in $DROPBOX"
|
||||||
if SPOTIFY_CLIENT_ID="$SPOTIFY_CLIENT_ID" SPOTIFY_CLIENT_SECRET="$SPOTIFY_CLIENT_SECRET" \
|
if SPOTIFY_CLIENT_ID="$SPOTIFY_CLIENT_ID" SPOTIFY_CLIENT_SECRET="$SPOTIFY_CLIENT_SECRET" \
|
||||||
|
SPOTIFY_REFRESH_TOKEN="$SPOTIFY_REFRESH_TOKEN" \
|
||||||
python3 ${PIPELINE_DIR:-/app/pipeline}/lib/spotify-retag.py "$SPOTIFY_URL" "$DROPBOX" >> "$LOG" 2>&1; then
|
python3 ${PIPELINE_DIR:-/app/pipeline}/lib/spotify-retag.py "$SPOTIFY_URL" "$DROPBOX" >> "$LOG" 2>&1; then
|
||||||
log "Spotify retag complete"
|
log "Spotify retag complete"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ pass = SOULSEEK_PASS
|
|||||||
# ==== Spotify API credentials ====
|
# ==== Spotify API credentials ====
|
||||||
spotify-id = SPOTIFY_CLIENT_ID
|
spotify-id = SPOTIFY_CLIENT_ID
|
||||||
spotify-secret = SPOTIFY_CLIENT_SECRET
|
spotify-secret = SPOTIFY_CLIENT_SECRET
|
||||||
|
# Set once an account is connected via /connect/spotify (blank otherwise).
|
||||||
|
# sldl's own docs confirm supplying a refresh token skips its interactive
|
||||||
|
# login-flow requirement entirely -- this is what lets newly created Spotify
|
||||||
|
# apps (which Spotify blocks from reading playlists with client-credentials
|
||||||
|
# alone) work without sldl ever trying to open a browser.
|
||||||
|
spotify-refresh = SPOTIFY_REFRESH_TOKEN
|
||||||
|
|
||||||
# ==== Input (Spotify playlist URL) ====
|
# ==== Input (Spotify playlist URL) ====
|
||||||
input = SPOTIFY_URL
|
input = SPOTIFY_URL
|
||||||
|
|||||||
@@ -12,19 +12,27 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
_TOKEN_URL = "https://accounts.spotify.com/api/token"
|
_TOKEN_URL = "https://accounts.spotify.com/api/token"
|
||||||
_cache: dict[tuple[str, str], str] = {}
|
_cache: dict[tuple[str, str, str | None], str] = {}
|
||||||
|
|
||||||
|
|
||||||
def get_token(client_id: str, client_secret: str) -> str:
|
def get_token(client_id: str, client_secret: str, refresh_token: str | None = None) -> str:
|
||||||
"""Return a client-credentials access token, cached per (id, secret) for
|
"""Return an access token, cached per (id, secret, refresh_token) for the
|
||||||
the life of the process."""
|
life of the process. If refresh_token is set (an account has been
|
||||||
key = (client_id, client_secret)
|
connected via /connect/spotify), mints a user token -- required for
|
||||||
|
playlist reads on newly created Spotify apps, which reject
|
||||||
|
client-credentials tokens. Otherwise falls back to client-credentials."""
|
||||||
|
key = (client_id, client_secret, refresh_token)
|
||||||
if key in _cache:
|
if key in _cache:
|
||||||
return _cache[key]
|
return _cache[key]
|
||||||
creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
||||||
|
grant = (
|
||||||
|
{"grant_type": "refresh_token", "refresh_token": refresh_token}
|
||||||
|
if refresh_token
|
||||||
|
else {"grant_type": "client_credentials"}
|
||||||
|
)
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
_TOKEN_URL,
|
_TOKEN_URL,
|
||||||
data=urllib.parse.urlencode({"grant_type": "client_credentials"}).encode(),
|
data=urllib.parse.urlencode(grant).encode(),
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Basic {creds}",
|
"Authorization": f"Basic {creds}",
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ def main() -> int:
|
|||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
print(f"[index] done in {elapsed:.0f}s. wrote {done - failed} rows, {failed} failed. "
|
print(f"[index] done in {elapsed:.0f}s. wrote {done - failed} rows, {failed} failed. "
|
||||||
f"index now has {total} entries.", flush=True)
|
f"index now has {total} entries.", flush=True)
|
||||||
return 0 if failed == 0 else 1
|
return 0 if (done - failed) > 0 else 1
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -294,6 +294,9 @@ def set_flac_tag(path, name, value):
|
|||||||
|
|
||||||
# ---- AzuraCast reprocess (same as backfill-buy-url.py) ----------------------
|
# ---- AzuraCast reprocess (same as backfill-buy-url.py) ----------------------
|
||||||
def az_reprocess(base, key, station, host_paths, library, log):
|
def az_reprocess(base, key, station, host_paths, library, log):
|
||||||
|
if not base:
|
||||||
|
log(" no AzuraCast base URL configured — skipping reprocess")
|
||||||
|
return
|
||||||
root = library.rstrip("/") + "/"
|
root = library.rstrip("/") + "/"
|
||||||
want = {p[len(root):] for p in host_paths if p.startswith(root)}
|
want = {p[len(root):] for p in host_paths if p.startswith(root)}
|
||||||
headers = {"X-API-Key": key, "Accept": "application/json"}
|
headers = {"X-API-Key": key, "Accept": "application/json"}
|
||||||
@@ -365,7 +368,7 @@ def refresh_qobuz_links(flacs, args, az_key):
|
|||||||
f"{converted} {'converted' if args.apply else 'would-convert'}, "
|
f"{converted} {'converted' if args.apply else 'would-convert'}, "
|
||||||
f"{unconvertible} left intact (not purchasable / unresolved)")
|
f"{unconvertible} left intact (not purchasable / unresolved)")
|
||||||
|
|
||||||
if args.apply and touched and az_key:
|
if args.apply and touched and az_key and args.azuracast_base:
|
||||||
print("[refresh-qobuz] telling AzuraCast to reprocess touched files...")
|
print("[refresh-qobuz] telling AzuraCast to reprocess touched files...")
|
||||||
az_reprocess(args.azuracast_base, az_key, args.station,
|
az_reprocess(args.azuracast_base, az_key, args.station,
|
||||||
touched, args.library, lambda m: print(m))
|
touched, args.library, lambda m: print(m))
|
||||||
@@ -415,6 +418,16 @@ def main():
|
|||||||
if os.path.exists(keyfile):
|
if os.path.exists(keyfile):
|
||||||
az_key = Path(keyfile).read_text().strip()
|
az_key = Path(keyfile).read_text().strip()
|
||||||
|
|
||||||
|
# Same cascade for the base URL: --azuracast-base, then env, then the file
|
||||||
|
# credential_service renders from Settings -> Credentials -> AzuraCast.
|
||||||
|
# The scheduled job (scheduler_service) invokes this script with neither
|
||||||
|
# the flag nor the env var set, so the keyfile is the only way it ever
|
||||||
|
# gets one.
|
||||||
|
if not args.azuracast_base:
|
||||||
|
basefile = f"{_ALEMBIC_CONFIG_DIR}/pipeline/azuracast/base_url"
|
||||||
|
if os.path.exists(basefile):
|
||||||
|
args.azuracast_base = Path(basefile).read_text().strip()
|
||||||
|
|
||||||
cascade = [s.strip() for s in args.sources.split(",") if s.strip() in SOURCES]
|
cascade = [s.strip() for s in args.sources.split(",") if s.strip() in SOURCES]
|
||||||
if not cascade:
|
if not cascade:
|
||||||
sys.exit(f"[enrich] no valid sources in {args.sources!r}")
|
sys.exit(f"[enrich] no valid sources in {args.sources!r}")
|
||||||
@@ -518,12 +531,12 @@ def main():
|
|||||||
f" no-match={looked - found}"
|
f" no-match={looked - found}"
|
||||||
+ (f" upgraded={upgraded}" if upgrade_from else ""))
|
+ (f" upgraded={upgraded}" if upgrade_from else ""))
|
||||||
|
|
||||||
if args.apply and touched and az_key:
|
if args.apply and touched and az_key and args.azuracast_base:
|
||||||
print("[enrich] telling AzuraCast to reprocess touched files...")
|
print("[enrich] telling AzuraCast to reprocess touched files...")
|
||||||
az_reprocess(args.azuracast_base, az_key, args.station,
|
az_reprocess(args.azuracast_base, az_key, args.station,
|
||||||
touched, args.library, lambda m: print(m))
|
touched, args.library, lambda m: print(m))
|
||||||
elif args.apply and touched and not az_key:
|
elif args.apply and touched and (not az_key or not args.azuracast_base):
|
||||||
print("[enrich] no AzuraCast key (flag/env/keyfile) — tags written; "
|
print("[enrich] no AzuraCast key/base URL (flag/env/keyfile) — tags written; "
|
||||||
"AzuraCast's periodic media scan will pick them up.")
|
"AzuraCast's periodic media scan will pick them up.")
|
||||||
elif not args.apply:
|
elif not args.apply:
|
||||||
print("[enrich] re-run with --apply to write (key via --azuracast-key, "
|
print("[enrich] re-run with --apply to write (key via --azuracast-key, "
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ def _spotify_token():
|
|||||||
continue
|
continue
|
||||||
k, v = line.split("=", 1)
|
k, v = line.split("=", 1)
|
||||||
env[k] = v.strip().strip("'").strip('"')
|
env[k] = v.strip().strip("'").strip('"')
|
||||||
return get_token(env["SPOTIFY_CLIENT_ID"], env["SPOTIFY_CLIENT_SECRET"])
|
return get_token(env["SPOTIFY_CLIENT_ID"], env["SPOTIFY_CLIENT_SECRET"],
|
||||||
|
env.get("SPOTIFY_REFRESH_TOKEN") or None)
|
||||||
|
|
||||||
|
|
||||||
def _spotify_get(path, token):
|
def _spotify_get(path, token):
|
||||||
|
|||||||
@@ -10,10 +10,21 @@ set -u
|
|||||||
CONFIGS="${ALEMBIC_CONFIG_DIR:-/config}/pipeline"
|
CONFIGS="${ALEMBIC_CONFIG_DIR:-/config}/pipeline"
|
||||||
LOG="${ALEMBIC_CONFIG_DIR:-/config}/logs/backfill-$(date +%Y%m%d-%H%M%S).log"
|
LOG="${ALEMBIC_CONFIG_DIR:-/config}/logs/backfill-$(date +%Y%m%d-%H%M%S).log"
|
||||||
RETAG="${PIPELINE_DIR:-/app/pipeline}/lib/spotify-retag.py"
|
RETAG="${PIPELINE_DIR:-/app/pipeline}/lib/spotify-retag.py"
|
||||||
|
SPOTIFY_ENV="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/_spotify.env"
|
||||||
|
|
||||||
mkdir -p "$(dirname "$LOG")"
|
mkdir -p "$(dirname "$LOG")"
|
||||||
echo "[$(date -Iseconds)] === Backfill start ===" | tee -a "$LOG"
|
echo "[$(date -Iseconds)] === Backfill start ===" | tee -a "$LOG"
|
||||||
|
|
||||||
|
# Spotify credentials (client id/secret, and a refresh token if an account is
|
||||||
|
# connected via /connect/spotify) are rendered once to _spotify.env as well as
|
||||||
|
# into each playlist .conf; read from the single shared file here rather than
|
||||||
|
# re-scraping every conf individually.
|
||||||
|
[ -f "$SPOTIFY_ENV" ] && source "$SPOTIFY_ENV"
|
||||||
|
if [[ -z "${SPOTIFY_CLIENT_ID:-}" || -z "${SPOTIFY_CLIENT_SECRET:-}" ]]; then
|
||||||
|
echo "[$(date -Iseconds)] ERROR: Spotify credentials not found at $SPOTIFY_ENV" | tee -a "$LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
TOTAL_TRACKS=0
|
TOTAL_TRACKS=0
|
||||||
TOTAL_PLAYLISTS=0
|
TOTAL_PLAYLISTS=0
|
||||||
|
|
||||||
@@ -22,11 +33,9 @@ for conf in "$CONFIGS"/*.conf; do
|
|||||||
playlist=$(basename "$conf" .conf)
|
playlist=$(basename "$conf" .conf)
|
||||||
|
|
||||||
url=$(sed -n 's/^input *= *//p' "$conf" | tr -d ' ')
|
url=$(sed -n 's/^input *= *//p' "$conf" | tr -d ' ')
|
||||||
cid=$(sed -n 's/^spotify-id *= *//p' "$conf" | tr -d ' ')
|
|
||||||
csec=$(sed -n 's/^spotify-secret *= *//p' "$conf" | tr -d ' ')
|
|
||||||
|
|
||||||
if [[ -z "$url" || -z "$cid" || -z "$csec" ]]; then
|
if [[ -z "$url" ]]; then
|
||||||
echo "[$playlist] SKIP: missing url/creds in conf" | tee -a "$LOG"
|
echo "[$playlist] SKIP: missing url in conf" | tee -a "$LOG"
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -41,7 +50,8 @@ for conf in "$CONFIGS"/*.conf; do
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[$playlist] retagging $count tracks via Spotify" | tee -a "$LOG"
|
echo "[$playlist] retagging $count tracks via Spotify" | tee -a "$LOG"
|
||||||
if echo "$paths" | SPOTIFY_CLIENT_ID="$cid" SPOTIFY_CLIENT_SECRET="$csec" \
|
if echo "$paths" | SPOTIFY_CLIENT_ID="$SPOTIFY_CLIENT_ID" SPOTIFY_CLIENT_SECRET="$SPOTIFY_CLIENT_SECRET" \
|
||||||
|
SPOTIFY_REFRESH_TOKEN="${SPOTIFY_REFRESH_TOKEN:-}" \
|
||||||
python3 "$RETAG" "$url" - >> "$LOG" 2>&1; then
|
python3 "$RETAG" "$url" - >> "$LOG" 2>&1; then
|
||||||
echo "[$playlist] retag OK" | tee -a "$LOG"
|
echo "[$playlist] retag OK" | tee -a "$LOG"
|
||||||
TOTAL_PLAYLISTS=$((TOTAL_PLAYLISTS + 1))
|
TOTAL_PLAYLISTS=$((TOTAL_PLAYLISTS + 1))
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ def _load_spotify_creds():
|
|||||||
continue
|
continue
|
||||||
k, v = line.split("=", 1)
|
k, v = line.split("=", 1)
|
||||||
env[k] = v.strip().strip("'").strip('"')
|
env[k] = v.strip().strip("'").strip('"')
|
||||||
return env["SPOTIFY_CLIENT_ID"], env["SPOTIFY_CLIENT_SECRET"]
|
return env["SPOTIFY_CLIENT_ID"], env["SPOTIFY_CLIENT_SECRET"], env.get("SPOTIFY_REFRESH_TOKEN") or None
|
||||||
|
|
||||||
SPOTIFY_CID, SPOTIFY_CSEC = _load_spotify_creds()
|
SPOTIFY_CID, SPOTIFY_CSEC, SPOTIFY_REFRESH = _load_spotify_creds()
|
||||||
WHITELIST_FILE = f"{ALEMBIC_CONFIG_DIR}/pipeline/genres-whitelist.txt"
|
WHITELIST_FILE = f"{ALEMBIC_CONFIG_DIR}/pipeline/genres-whitelist.txt"
|
||||||
MAX_GENRES = 3
|
MAX_GENRES = 3
|
||||||
SEPARATOR = "; "
|
SEPARATOR = "; "
|
||||||
@@ -76,7 +76,7 @@ def load_whitelist():
|
|||||||
|
|
||||||
|
|
||||||
def _spotify_token():
|
def _spotify_token():
|
||||||
return get_token(SPOTIFY_CID, SPOTIFY_CSEC)
|
return get_token(SPOTIFY_CID, SPOTIFY_CSEC, SPOTIFY_REFRESH)
|
||||||
|
|
||||||
|
|
||||||
# Cache: artist_name → list[str] of Spotify genres (or None if not found)
|
# Cache: artist_name → list[str] of Spotify genres (or None if not found)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ Usage:
|
|||||||
spotify-retag.py <playlist_url> - # read newline-separated paths from stdin
|
spotify-retag.py <playlist_url> - # read newline-separated paths from stdin
|
||||||
|
|
||||||
Credentials are read from env vars SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET.
|
Credentials are read from env vars SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET.
|
||||||
|
SPOTIFY_REFRESH_TOKEN, if set, mints a user token instead of client-credentials
|
||||||
|
(required for newly created Spotify apps -- see _spotify_auth.get_token).
|
||||||
|
|
||||||
Matching strategy (per file):
|
Matching strategy (per file):
|
||||||
1. Parse "Artist - Title.ext" from filename. If multiple " - " separators,
|
1. Parse "Artist - Title.ext" from filename. If multiple " - " separators,
|
||||||
@@ -46,7 +48,8 @@ def fetch_playlist(url: str, token: str) -> list[dict]:
|
|||||||
sys.exit(f"Could not parse playlist ID from {url!r}")
|
sys.exit(f"Could not parse playlist ID from {url!r}")
|
||||||
pid = m.group(1)
|
pid = m.group(1)
|
||||||
tracks = []
|
tracks = []
|
||||||
next_url = f"{API}/playlists/{pid}/tracks?limit=100"
|
# /items (not the removed /tracks) -- see February 2026 Spotify API changes.
|
||||||
|
next_url = f"{API}/playlists/{pid}/items?limit=100"
|
||||||
while next_url:
|
while next_url:
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
next_url, headers={"Authorization": f"Bearer {token}"}
|
next_url, headers={"Authorization": f"Bearer {token}"}
|
||||||
@@ -222,6 +225,7 @@ def main() -> int:
|
|||||||
url, target = sys.argv[1], sys.argv[2]
|
url, target = sys.argv[1], sys.argv[2]
|
||||||
cid = os.environ.get("SPOTIFY_CLIENT_ID")
|
cid = os.environ.get("SPOTIFY_CLIENT_ID")
|
||||||
csec = os.environ.get("SPOTIFY_CLIENT_SECRET")
|
csec = os.environ.get("SPOTIFY_CLIENT_SECRET")
|
||||||
|
refresh = os.environ.get("SPOTIFY_REFRESH_TOKEN") or None
|
||||||
if not cid or not csec:
|
if not cid or not csec:
|
||||||
sys.exit("SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET must be set")
|
sys.exit("SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET must be set")
|
||||||
|
|
||||||
@@ -231,7 +235,7 @@ def main() -> int:
|
|||||||
files_iter = Path(target).rglob("*")
|
files_iter = Path(target).rglob("*")
|
||||||
|
|
||||||
print(f"[spotify-retag] Fetching playlist {url}")
|
print(f"[spotify-retag] Fetching playlist {url}")
|
||||||
token = get_token(cid, csec)
|
token = get_token(cid, csec, refresh)
|
||||||
tracks = fetch_playlist(url, token)
|
tracks = fetch_playlist(url, token)
|
||||||
print(f"[spotify-retag] Got {len(tracks)} tracks from Spotify")
|
print(f"[spotify-retag] Got {len(tracks)} tracks from Spotify")
|
||||||
|
|
||||||
|
|||||||
@@ -45,10 +45,37 @@ def test_render_playlist_confs_injects_creds_safely(db, config_dir):
|
|||||||
assert "pass = p'a$(id)ss" in text
|
assert "pass = p'a$(id)ss" in text
|
||||||
assert "spotify-id = CID" in text
|
assert "spotify-id = CID" in text
|
||||||
assert "spotify-secret = CSECRET" in text
|
assert "spotify-secret = CSECRET" in text
|
||||||
|
# no account connected yet -- rendered blank, not left as the placeholder
|
||||||
|
assert "spotify-refresh = \n" in text
|
||||||
# rendered config must be owner-only (holds the Soulseek password)
|
# rendered config must be owner-only (holds the Soulseek password)
|
||||||
assert stat.S_IMODE(os.stat(conf).st_mode) == 0o600
|
assert stat.S_IMODE(os.stat(conf).st_mode) == 0o600
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_playlist_confs_includes_spotify_refresh_when_connected(db, config_dir):
|
||||||
|
cs.set_credentials(db, "spotify", {"client_id": "CID", "client_secret": "CSECRET", "refresh_token": "RTOK"})
|
||||||
|
cs.set_credentials(db, "soulseek", {"username": "seek", "password": "pw"})
|
||||||
|
pl.create(db, name="rtest2", spotify_url="https://open.spotify.com/playlist/Z")
|
||||||
|
|
||||||
|
conf = config_dir / "pipeline" / "rtest2.conf"
|
||||||
|
text = conf.read_text()
|
||||||
|
assert "spotify-refresh = RTOK" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_azuracast_renders_and_clears_base_url(db, config_dir):
|
||||||
|
cs.set_credentials(db, "azuracast", {"base_url": "https://radio.example.com", "api_key": "KEY"})
|
||||||
|
|
||||||
|
az_dir = config_dir / "pipeline" / "azuracast"
|
||||||
|
assert (az_dir / "base_url").read_text() == "https://radio.example.com"
|
||||||
|
assert (az_dir / "api_key").read_text() == "KEY"
|
||||||
|
|
||||||
|
cs.set_scope_enabled(db, "azuracast", False)
|
||||||
|
assert not (az_dir / "base_url").exists()
|
||||||
|
assert not (az_dir / "api_key").exists()
|
||||||
|
|
||||||
|
cs.set_scope_enabled(db, "azuracast", True)
|
||||||
|
assert (az_dir / "base_url").read_text() == "https://radio.example.com"
|
||||||
|
|
||||||
|
|
||||||
def test_bandcamp_cookie_expiry_parsing():
|
def test_bandcamp_cookie_expiry_parsing():
|
||||||
jar = "\n".join([
|
jar = "\n".join([
|
||||||
"# Netscape HTTP Cookie File",
|
"# Netscape HTTP Cookie File",
|
||||||
|
|||||||
Reference in New Issue
Block a user