Security hardening and first-run/portability improvements
Security (P0): - Remove committed session-secret default; auto-generate and persist a random secret to the config volume when SESSION_SECRET is unset (prevents forgeable session cookies / auth bypass). - Validate playlist names to a safe charset and render sldl configs via literal Python substitution instead of sed (closes a command-injection and path-traversal path through playlist names). - shlex-quote credential values written to shell-sourced env files, and strip newlines from values patched into .conf files. - Render playlist .conf files 0600; warn at startup if the master key is co-located with the config volume; document keeping it separate. Portability: - Configurable timezone via TZ (default UTC) instead of hardcoded Edmonton. - Remove personal defaults (navidrome user "andrew", ephemeral.club URLs). - Ship generic example seeds; move the cross-album dedup keep-list and the legacy playlist import to editable config files; drop the personal _upgrade.csv. - Generic VPN reference in docker-compose.snippet.yml. First-run experience: - Redirect to /setup instead of 500 when OIDC is unconfigured; surface a missing master key inline; entrypoint exits with an actionable message when the config folder is not writable. - Add unauthenticated /health (JSON) and /setup (checklist) diagnostics. Docs: - Write docs/ARCHITECTURE.md and docs/MIGRATION.md (previously referenced but missing); expand README with ownership, backups, advanced settings, and migration guidance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,3 +5,4 @@ __pycache__/
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
.env
|
.env
|
||||||
|
pipeline/configs/_upgrade.csv
|
||||||
|
|||||||
@@ -82,29 +82,40 @@ This takes a few minutes the first time. You don't need to touch anything inside
|
|||||||
|
|
||||||
### 2. Create your folders
|
### 2. Create your folders
|
||||||
|
|
||||||
alembic needs exactly two places to store things:
|
alembic needs three places to store things:
|
||||||
|
|
||||||
- **A music folder** — where your actual songs live. If you already have a music library, point at it directly.
|
- **A music folder**, where your actual songs live. If you already have a music library, point at it directly.
|
||||||
- **A config folder** — where alembic keeps its own database, your saved credentials, and logs.
|
- **A config folder**, where alembic keeps its own database, your saved credentials (encrypted), and logs.
|
||||||
|
- **A secrets folder**, which holds just one file: the master key described in the next step. This is kept separate from the config folder on purpose (see below).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p /path/to/your/music
|
mkdir -p /path/to/your/music
|
||||||
mkdir -p /path/to/alembic-config
|
mkdir -p /path/to/alembic-config
|
||||||
|
mkdir -p /path/to/alembic-secrets
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything else (which playlists you follow, your credentials, scheduling) is stored inside that config folder, so if you ever need to move alembic to a new machine, copying those two folders is all it takes.
|
alembic runs inside the container as a non-root user (user and group id 1000). If you created these folders as yourself or as root, the container will not be able to write to them and will stop on startup with a clear message telling you this. Give that user ownership of the config and music folders:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chown -R 1000:1000 /path/to/alembic-config
|
||||||
|
sudo chown -R 1000:1000 /path/to/your/music
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything about how alembic runs (which playlists you follow, your credentials, scheduling) is stored inside the config folder, so moving alembic to a new machine is a matter of copying it across. Back up the secrets folder separately, and do not keep it in the same backup as the config folder. The reason is in the next step.
|
||||||
|
|
||||||
### 3. Generate a master key
|
### 3. Generate a master key
|
||||||
|
|
||||||
alembic encrypts every credential you give it (Spotify keys, Soulseek password, and so on) before storing it. It needs a key to do that:
|
alembic encrypts every credential you give it (Spotify keys, Soulseek password, and so on) before storing it in the config folder. It needs a key to do that:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
openssl rand -base64 32 > /path/to/alembic-config/master.key
|
openssl rand -base64 32 > /path/to/alembic-secrets/master.key
|
||||||
chmod 600 /path/to/alembic-config/master.key
|
chmod 600 /path/to/alembic-secrets/master.key
|
||||||
```
|
```
|
||||||
|
|
||||||
Keep this file safe. If you lose it, alembic can no longer read your saved credentials and you'll need to re-enter them.
|
Keep this file safe. If you lose it, alembic can no longer read your saved credentials and you'll need to re-enter them.
|
||||||
|
|
||||||
|
Keep it separate from your config folder, which is why it goes in its own folder above. The config folder holds the encrypted credentials. If the key sat in there too, then anyone who got hold of one backup would have both the locked box and its key, and the encryption would not protect you. Storing the key on its own keeps the two apart.
|
||||||
|
|
||||||
### 4. Set up login (OIDC)
|
### 4. Set up login (OIDC)
|
||||||
|
|
||||||
Register alembic as an application in your OIDC provider (Pocket ID, Authentik, whichever you use). You'll need to tell it:
|
Register alembic as an application in your OIDC provider (Pocket ID, Authentik, whichever you use). You'll need to tell it:
|
||||||
@@ -145,12 +156,12 @@ services:
|
|||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
alembic_master_key:
|
alembic_master_key:
|
||||||
file: /path/to/alembic-config/master.key
|
file: /path/to/alembic-secrets/master.key
|
||||||
```
|
```
|
||||||
|
|
||||||
A few notes on the environment values:
|
A few notes on the environment values:
|
||||||
|
|
||||||
- `SESSION_SECRET` can be any long random string, generate one with `openssl rand -base64 32`.
|
- `SESSION_SECRET` signs your login session. You can leave it out and alembic will generate one on first start and save it inside the config folder for next time. If you would rather set it yourself, use any long random string, for example from `openssl rand -base64 32`.
|
||||||
- `ALLOWED_EMAIL` is optional but recommended. Even if your OIDC provider only has your account today, this makes sure only that one email can ever log in.
|
- `ALLOWED_EMAIL` is optional but recommended. Even if your OIDC provider only has your account today, this makes sure only that one email can ever log in.
|
||||||
- If you want to route Soulseek traffic through a VPN container instead of exposing alembic's port directly, see the comments in `docker-compose.snippet.yml` in this repo for the `network_mode: "service:<vpn-container>"` pattern. Most people can skip this and use the simple port mapping above.
|
- If you want to route Soulseek traffic through a VPN container instead of exposing alembic's port directly, see the comments in `docker-compose.snippet.yml` in this repo for the `network_mode: "service:<vpn-container>"` pattern. Most people can skip this and use the simple port mapping above.
|
||||||
|
|
||||||
@@ -218,10 +229,40 @@ docker build -t alembic:latest .
|
|||||||
docker compose up -d --force-recreate alembic
|
docker compose up -d --force-recreate alembic
|
||||||
```
|
```
|
||||||
|
|
||||||
Your library, credentials, and settings all live in your two mounted folders, so updating the image never touches your data.
|
Your library, credentials, and settings all live in your mounted folders, so updating the image never touches your data.
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
Everything worth keeping is in the folders you already created:
|
||||||
|
|
||||||
|
- **The config folder** holds the app database, the beets library database, your rendered settings, and your encrypted credentials. Backing this up captures your playlists, schedules, and history.
|
||||||
|
- **The secrets folder** holds `master.key`. Back this up too, but keep it in a separate backup from the config folder. The config folder holds your credentials in encrypted form, and the key is what unlocks them. Keeping the two apart means one leaked backup is not enough to read your credentials.
|
||||||
|
- **The music folder** is your library itself. Back it up however you already back up files.
|
||||||
|
|
||||||
|
To restore on a new machine: put the config and secrets folders back where they were, point your compose file at them, and start the container. If you ever lose `master.key`, alembic can still start, but it can no longer read your saved credentials, so you would re-enter them once under Settings then Credentials.
|
||||||
|
|
||||||
|
## Advanced settings
|
||||||
|
|
||||||
|
Most people never touch these. They are environment variables you can add to your compose file's `environment:` block.
|
||||||
|
|
||||||
|
- `TZ` sets the timezone used for scheduling and for times shown in the app, for example `TZ=America/Toronto`. Defaults to UTC. This is the standard Docker timezone variable, so if you already set it for other containers, alembic follows it too.
|
||||||
|
- `ALLOWED_EMAIL` locks login to a single email address (recommended for a personal server).
|
||||||
|
- `MUSIC_DATA_DIR`, `ALEMBIC_CONFIG_DIR`, `ALEMBIC_PORT`, and `ENCRYPTION_KEY_FILE` let you change the in-container paths and port. The defaults match everything in this README, so you only need these for unusual setups.
|
||||||
|
|
||||||
|
You can confirm your setup at any time by visiting `/setup` in your browser (no login needed), which lists what is configured and what is missing. There is also a `/health` endpoint that returns the same information as JSON for uptime monitors.
|
||||||
|
|
||||||
|
## Migrating from the older script version
|
||||||
|
|
||||||
|
If you used the earlier version of this project, when it was a set of scripts rather than this container, there is a short guide for bringing your old playlists and credentials across in [docs/MIGRATION.md](docs/MIGRATION.md).
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Something isn't working and I'm not sure what.**
|
||||||
|
Open `/setup` in your browser. It checks the things a fresh install commonly gets wrong (login not configured, master key missing, config folder not writable) and tells you exactly what to fix. You do not need to be logged in to see it.
|
||||||
|
|
||||||
|
**The container won't start / exits immediately.**
|
||||||
|
Check the logs with `docker compose logs alembic`. The most common cause is folder ownership: the container runs as user id 1000, so the config and music folders must be owned by it. Run `sudo chown -R 1000:1000 <folder>` on each, as described in step 2.
|
||||||
|
|
||||||
**I can't log in / get redirected in a loop.**
|
**I can't log in / get redirected in a loop.**
|
||||||
Double check the redirect URI registered with your OIDC provider exactly matches `https://<your-host>/auth/callback`, including `http` vs `https`.
|
Double check the redirect URI registered with your OIDC provider exactly matches `https://<your-host>/auth/callback`, including `http` vs `https`.
|
||||||
|
|
||||||
|
|||||||
+29
-2
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -5,13 +6,38 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from app.db import enable_beets_db_wal, init_db
|
from app.db import enable_beets_db_wal, init_db
|
||||||
from app.routers import artist_casing, auth, credentials, dashboard, dedup, genres, import_, jobs, library, playlists
|
from app.routers import artist_casing, auth, credentials, dashboard, dedup, genres, health, import_, jobs, library, playlists
|
||||||
|
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
|
||||||
|
|
||||||
|
log = logging.getLogger("alembic")
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_if_key_colocated_with_config() -> None:
|
||||||
|
"""The Fernet master key decrypts every stored credential. If it lives
|
||||||
|
inside the config volume, one leaked volume or backup exposes both the
|
||||||
|
key and the encrypted database together, which defeats the encryption.
|
||||||
|
Warn loudly rather than fail, since some users deliberately accept this."""
|
||||||
|
try:
|
||||||
|
key_path = settings.encryption_key_file.resolve()
|
||||||
|
config_dir = settings.alembic_config_dir.resolve()
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
if config_dir == key_path or config_dir in key_path.parents:
|
||||||
|
log.warning(
|
||||||
|
"Master key %s is inside the config directory %s. Anyone with a "
|
||||||
|
"copy of that volume or backup gets both the key and the encrypted "
|
||||||
|
"credentials. Mount the key from a separate location kept out of "
|
||||||
|
"the config backup. See docker-compose.snippet.yml.",
|
||||||
|
key_path,
|
||||||
|
config_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI):
|
async def lifespan(_app: FastAPI):
|
||||||
|
_warn_if_key_colocated_with_config()
|
||||||
init_db()
|
init_db()
|
||||||
enable_beets_db_wal()
|
enable_beets_db_wal()
|
||||||
|
|
||||||
@@ -27,10 +53,11 @@ async def lifespan(_app: FastAPI):
|
|||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
app = FastAPI(title="alembic", lifespan=lifespan)
|
app = FastAPI(title="alembic", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(SessionMiddleware, secret_key=settings.session_secret)
|
app.add_middleware(SessionMiddleware, secret_key=resolve_session_secret())
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
|
|
||||||
|
app.include_router(health.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
app.include_router(library.router)
|
app.include_router(library.router)
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ from sqlalchemy import select
|
|||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.security.oidc import oauth
|
from app.security.oidc import oauth
|
||||||
|
from app.services import diagnostics
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login")
|
@router.get("/login")
|
||||||
async def login(request: Request):
|
async def login(request: Request):
|
||||||
|
# If OIDC isn't configured, the client was never registered and calling it
|
||||||
|
# would raise an opaque 500. Send the operator to the setup check instead,
|
||||||
|
# which spells out exactly which variables are missing.
|
||||||
|
if not diagnostics.oidc_configured():
|
||||||
|
return RedirectResponse(url="/setup")
|
||||||
redirect_uri = request.url_for("auth_callback")
|
redirect_uri = request.url_for("auth_callback")
|
||||||
return await oauth.pocketid.authorize_redirect(request, redirect_uri)
|
return await oauth.pocketid.authorize_redirect(request, redirect_uri)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi.responses import RedirectResponse
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
|
from app.security import crypto
|
||||||
from app.security.deps import require_auth
|
from app.security.deps import require_auth
|
||||||
from app.services import credential_service
|
from app.services import credential_service
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ async def credentials_index(request: Request, user: dict = Depends(require_auth)
|
|||||||
"core_scopes": credential_service.CORE_SCOPES,
|
"core_scopes": credential_service.CORE_SCOPES,
|
||||||
"configured": configured,
|
"configured": configured,
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
|
"key_present": crypto.key_present(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,7 +49,13 @@ async def save_credentials(
|
|||||||
for field, value in form.items()
|
for field, value in form.items()
|
||||||
if field in credential_service.SCOPE_FIELDS[scope] and value != ""
|
if field in credential_service.SCOPE_FIELDS[scope] and value != ""
|
||||||
}
|
}
|
||||||
credential_service.set_credentials(db, scope, values)
|
try:
|
||||||
|
credential_service.set_credentials(db, scope, values)
|
||||||
|
except RuntimeError:
|
||||||
|
# Almost always the encryption key is missing, so the value can't be
|
||||||
|
# encrypted. Redirect back; the page shows a key-missing banner with
|
||||||
|
# exactly what to do.
|
||||||
|
return RedirectResponse(url="/settings/credentials", status_code=303)
|
||||||
return RedirectResponse(url="/settings/credentials", status_code=303)
|
return RedirectResponse(url="/settings/credentials", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy import select
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import JobRun
|
from app.models import JobRun
|
||||||
from app.security.deps import require_auth
|
from app.security.deps import require_auth
|
||||||
from app.services import beets_service, credential_service, network_service, playlist_service, scheduler_service
|
from app.services import beets_service, credential_service, diagnostics, network_service, playlist_service, scheduler_service
|
||||||
|
|
||||||
router = APIRouter(tags=["dashboard"])
|
router = APIRouter(tags=["dashboard"])
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
@@ -34,11 +34,13 @@ async def dashboard(request: Request, user: dict = Depends(require_auth), db=Dep
|
|||||||
)
|
)
|
||||||
recent_tracks = beets_service.recently_added(time.time() - _RECENT_TRACKS_WINDOW_SECONDS)
|
recent_tracks = beets_service.recently_added(time.time() - _RECENT_TRACKS_WINDOW_SECONDS)
|
||||||
public_ip = await asyncio.to_thread(network_service.public_ip)
|
public_ip = await asyncio.to_thread(network_service.public_ip)
|
||||||
|
setup_warnings = [c for c in diagnostics.checks() if not c["ok"] and (c["critical"] or c.get("warn"))]
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
{
|
{
|
||||||
"user": user,
|
"user": user,
|
||||||
|
"setup_warnings": setup_warnings,
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
"total_size_human": _human_bytes(stats.get("total_bytes", 0)),
|
"total_size_human": _human_bytes(stats.get("total_bytes", 0)),
|
||||||
"playlist_count": len(playlists),
|
"playlist_count": len(playlists),
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app.services import diagnostics
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health():
|
||||||
|
"""Unauthenticated JSON health check. 200 when everything critical is
|
||||||
|
configured, 503 when something critical is missing so an uptime monitor
|
||||||
|
notices. The body always lists every check either way."""
|
||||||
|
result = diagnostics.summary()
|
||||||
|
status_code = 200 if result["status"] == "ok" else 503
|
||||||
|
return JSONResponse(result, status_code=status_code)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/setup")
|
||||||
|
async def setup(request: Request):
|
||||||
|
"""Unauthenticated, human-readable version of the same checks, so a fresh
|
||||||
|
operator can see what still needs configuring before login works."""
|
||||||
|
result = diagnostics.summary()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"setup.html",
|
||||||
|
{"status": result["status"], "checks": result["checks"]},
|
||||||
|
)
|
||||||
@@ -28,13 +28,16 @@ async def create_playlist(
|
|||||||
user: dict = Depends(require_auth),
|
user: dict = Depends(require_auth),
|
||||||
db=Depends(get_db),
|
db=Depends(get_db),
|
||||||
):
|
):
|
||||||
playlist_service.create(
|
try:
|
||||||
db,
|
playlist_service.create(
|
||||||
name=name,
|
db,
|
||||||
spotify_url=spotify_url,
|
name=name,
|
||||||
cron_expr=playlist_service.time_to_cron(sync_time),
|
spotify_url=spotify_url,
|
||||||
no_m3u=no_m3u,
|
cron_expr=playlist_service.time_to_cron(sync_time),
|
||||||
)
|
no_m3u=no_m3u,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
return RedirectResponse(url="/playlists", status_code=303)
|
return RedirectResponse(url="/playlists", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ def _fernet() -> Fernet:
|
|||||||
return Fernet(key_path.read_bytes().strip())
|
return Fernet(key_path.read_bytes().strip())
|
||||||
|
|
||||||
|
|
||||||
|
def key_present() -> bool:
|
||||||
|
return settings.encryption_key_file.exists()
|
||||||
|
|
||||||
|
|
||||||
def encrypt(value: str) -> bytes:
|
def encrypt(value: str) -> bytes:
|
||||||
return _fernet().encrypt(value.encode())
|
return _fernet().encrypt(value.encode())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import secrets
|
||||||
|
|
||||||
|
from app.settings import settings
|
||||||
|
|
||||||
|
# A leftover from an earlier build that shipped this as the default. Still
|
||||||
|
# rejected explicitly so anyone who copied it into their env doesn't end up
|
||||||
|
# with a publicly-known signing key.
|
||||||
|
_KNOWN_PLACEHOLDER = "changeme-dev-only-override-in-production"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_session_secret() -> str:
|
||||||
|
"""Return the secret used to sign session cookies.
|
||||||
|
|
||||||
|
Preference order:
|
||||||
|
1. SESSION_SECRET from the environment, if set to a real value.
|
||||||
|
2. A random secret persisted to the config volume, generated once on
|
||||||
|
first boot and reused on every restart after that.
|
||||||
|
|
||||||
|
This means a user who never sets SESSION_SECRET still gets a strong,
|
||||||
|
stable secret instead of the app silently falling back to a constant
|
||||||
|
that would make every session cookie forgeable.
|
||||||
|
"""
|
||||||
|
env_value = settings.session_secret.strip()
|
||||||
|
if env_value and env_value != _KNOWN_PLACEHOLDER:
|
||||||
|
return env_value
|
||||||
|
|
||||||
|
secret_path = settings.alembic_config_dir / "session.secret"
|
||||||
|
if secret_path.exists():
|
||||||
|
stored = secret_path.read_text().strip()
|
||||||
|
if stored:
|
||||||
|
return stored
|
||||||
|
|
||||||
|
generated = secrets.token_urlsafe(48)
|
||||||
|
secret_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
secret_path.write_text(generated + "\n")
|
||||||
|
secret_path.chmod(0o600)
|
||||||
|
return generated
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ SCOPE_DESCRIPTIONS = {
|
|||||||
"navidrome": "Triggers a library rescan after downloads/imports/edits, and lets the daily status report check Navidrome's scan health. 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.",
|
"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.",
|
"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.",
|
"azuracast": "Only relevant if you run an AzuraCast radio station off the same library. Not a buy-link source itself (that's Bandcamp/Qobuz, written straight to the file tag) -- this lets alembic tell AzuraCast 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.",
|
"telegram": "Sends the daily pipeline health digest to a chat instead of you having to check the dashboard.",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +100,9 @@ def _patch_conf_field(path: Path, key: str, value: str) -> None:
|
|||||||
every other line (including PLAYLIST_NAME/SPOTIFY_URL substitutions
|
every other line (including PLAYLIST_NAME/SPOTIFY_URL substitutions
|
||||||
regen.sh already applied) untouched."""
|
regen.sh already applied) untouched."""
|
||||||
text = path.read_text()
|
text = path.read_text()
|
||||||
|
# A newline in the value would inject an extra `key = value` line into the
|
||||||
|
# sldl config. Credentials never legitimately contain one, so strip any.
|
||||||
|
value = value.replace("\r", "").replace("\n", "")
|
||||||
pattern = re.compile(rf"^{re.escape(key)}\s*=.*$", re.MULTILINE)
|
pattern = re.compile(rf"^{re.escape(key)}\s*=.*$", re.MULTILINE)
|
||||||
new_line = f"{key} = {value}"
|
new_line = f"{key} = {value}"
|
||||||
if pattern.search(text):
|
if pattern.search(text):
|
||||||
@@ -114,7 +118,11 @@ def _every_playlist_conf() -> list[Path]:
|
|||||||
|
|
||||||
def _write_env_file(path: Path, values: dict[str, str]) -> None:
|
def _write_env_file(path: Path, values: dict[str, str]) -> None:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
lines = [f"{k}='{v}'" for k, v in values.items()]
|
# shlex.quote so a credential value containing a quote, space, or shell
|
||||||
|
# metacharacter (e.g. a password like `p'a$(id)ss`) stays a single literal
|
||||||
|
# value when a pipeline script `source`s this file, instead of breaking out
|
||||||
|
# and executing. shlex.quote also handles the empty string correctly ('').
|
||||||
|
lines = [f"{k}={shlex.quote(v)}" for k, v in values.items()]
|
||||||
path.write_text("\n".join(lines) + "\n")
|
path.write_text("\n".join(lines) + "\n")
|
||||||
path.chmod(0o600)
|
path.chmod(0o600)
|
||||||
|
|
||||||
@@ -147,7 +155,7 @@ def render_scope(db: Session, scope: str) -> None:
|
|||||||
settings.pipeline_config_dir / "navidrome" / "admin.env",
|
settings.pipeline_config_dir / "navidrome" / "admin.env",
|
||||||
{
|
{
|
||||||
"ND_BASE": values.get("base_url", "http://navidrome:4533"),
|
"ND_BASE": values.get("base_url", "http://navidrome:4533"),
|
||||||
"ND_USER": values.get("admin_user", "andrew"),
|
"ND_USER": values.get("admin_user", ""),
|
||||||
"ND_PASS": values.get("admin_pass", ""),
|
"ND_PASS": values.get("admin_pass", ""),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Setup and health checks.
|
||||||
|
|
||||||
|
These answer "is this install configured enough to work?" without needing a
|
||||||
|
login, so a fresh operator can see what is missing before the point where a
|
||||||
|
missing piece would otherwise surface as an opaque 500. Nothing here reads or
|
||||||
|
exposes a secret value; it only reports presence and reachability.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.settings import settings
|
||||||
|
|
||||||
|
# Checks whose failure means the app cannot function (can't log in, can't
|
||||||
|
# persist state). Everything else is informational.
|
||||||
|
CRITICAL = {"oidc", "master_key", "config_writable"}
|
||||||
|
|
||||||
|
|
||||||
|
def _config_writable() -> bool:
|
||||||
|
probe = settings.alembic_config_dir / ".write-probe"
|
||||||
|
try:
|
||||||
|
probe.write_text("ok")
|
||||||
|
probe.unlink()
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def oidc_configured() -> bool:
|
||||||
|
return bool(
|
||||||
|
settings.pocketid_issuer
|
||||||
|
and settings.pocketid_client_id
|
||||||
|
and settings.pocketid_client_secret
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def master_key_present() -> bool:
|
||||||
|
return settings.encryption_key_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _key_colocated() -> bool:
|
||||||
|
try:
|
||||||
|
key = settings.encryption_key_file.resolve()
|
||||||
|
cfg = settings.alembic_config_dir.resolve()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return cfg == key or cfg in key.parents
|
||||||
|
|
||||||
|
|
||||||
|
def checks() -> list[dict]:
|
||||||
|
"""Return one dict per check: key, ok, critical, detail."""
|
||||||
|
results = [
|
||||||
|
{
|
||||||
|
"key": "oidc",
|
||||||
|
"ok": oidc_configured(),
|
||||||
|
"detail": "Login provider configured."
|
||||||
|
if oidc_configured()
|
||||||
|
else "Set POCKETID_ISSUER, POCKETID_CLIENT_ID and POCKETID_CLIENT_SECRET. Without them, login cannot work.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "master_key",
|
||||||
|
"ok": master_key_present(),
|
||||||
|
"detail": f"Encryption key found at {settings.encryption_key_file}."
|
||||||
|
if master_key_present()
|
||||||
|
else f"No encryption key at {settings.encryption_key_file}. Generate one with `openssl rand -base64 32` and mount it as the alembic_master_key secret. Saving any credential will fail until then.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "config_writable",
|
||||||
|
"ok": _config_writable(),
|
||||||
|
"detail": f"Config directory {settings.alembic_config_dir} is writable."
|
||||||
|
if _config_writable()
|
||||||
|
else f"Cannot write to {settings.alembic_config_dir}. Fix ownership on the host so the container user (uid 1000) can write: chown -R 1000:1000 <your config folder>.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "music_dir",
|
||||||
|
"ok": os.path.isdir(settings.music_data_dir),
|
||||||
|
"detail": f"Music directory {settings.music_data_dir} is present."
|
||||||
|
if os.path.isdir(settings.music_data_dir)
|
||||||
|
else f"Music directory {settings.music_data_dir} is not mounted.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "beets_library",
|
||||||
|
"ok": True, # absence is normal before the first import
|
||||||
|
"detail": "Library database present."
|
||||||
|
if settings.beets_db_path.exists()
|
||||||
|
else "No library database yet. This is normal until your first playlist sync or import.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if master_key_present() and _key_colocated():
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"key": "key_location",
|
||||||
|
"ok": True,
|
||||||
|
"detail": "Note: the master key sits inside the config directory. A single leaked backup would expose both the key and the encrypted credentials. Consider mounting it from a separate location.",
|
||||||
|
"warn": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for r in results:
|
||||||
|
r["critical"] = r["key"] in CRITICAL
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def summary() -> dict:
|
||||||
|
"""Overall status plus the per-check list, for /health."""
|
||||||
|
results = checks()
|
||||||
|
degraded = any(c["critical"] and not c["ok"] for c in results)
|
||||||
|
return {"status": "degraded" if degraded else "ok", "checks": results}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -8,6 +9,26 @@ from sqlalchemy.orm import Session
|
|||||||
from app.models import Playlist
|
from app.models import Playlist
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
||||||
|
# A playlist name becomes a filename (<name>.conf), a directory under the
|
||||||
|
# Soulseek dropbox, an M3U path, and a log path, and it is substituted into
|
||||||
|
# rendered config files. Restrict it to a small safe charset so it can never
|
||||||
|
# escape those paths (../) or inject config/shell content. Kept deliberately
|
||||||
|
# tight; if a user wants a fancier display name that's a separate field to add
|
||||||
|
# later, not a reason to loosen the on-disk identifier.
|
||||||
|
_VALID_NAME = re.compile(r"^[A-Za-z0-9 _-]{1,64}$")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_name(name: str) -> str:
|
||||||
|
"""Return the name unchanged if it is a safe on-disk identifier, else
|
||||||
|
raise ValueError with a message suitable for showing to the user."""
|
||||||
|
cleaned = (name or "").strip()
|
||||||
|
if not _VALID_NAME.match(cleaned):
|
||||||
|
raise ValueError(
|
||||||
|
"Playlist name must be 1-64 characters using only letters, "
|
||||||
|
"numbers, spaces, hyphens and underscores."
|
||||||
|
)
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
def cron_to_time(cron_expr: str | None) -> str:
|
def cron_to_time(cron_expr: str | None) -> str:
|
||||||
"""'30 1 * * *' -> '01:30'. Every playlist schedule today is a simple
|
"""'30 1 * * *' -> '01:30'. Every playlist schedule today is a simple
|
||||||
@@ -41,32 +62,6 @@ def time_to_cron(time_str: str | None) -> str | None:
|
|||||||
return None
|
return None
|
||||||
return f"{minute} {hour} * * *"
|
return f"{minute} {hour} * * *"
|
||||||
|
|
||||||
# The 17-entry array this project is migrating off of (was hardcoded in
|
|
||||||
# /opt/sldl/configs/regen.sh). Used once by seed_legacy() during Stage 1/2
|
|
||||||
# of the migration; the DB is the source of truth from then on. cron_expr
|
|
||||||
# values are the exact staggered slots from /etc/cron.d/sldl-maintenance,
|
|
||||||
# preserved so cutover doesn't change anyone's download schedule.
|
|
||||||
LEGACY_PLAYLISTS = {
|
|
||||||
"digicore": ("https://open.spotify.com/playlist/6tWHtPBECZTkZHPKFA3Fq4", "0 0 * * *", False),
|
|
||||||
"techno": ("https://open.spotify.com/playlist/5GlAkczmLWJFIJO4FvkLEZ", "0 1 * * *", False),
|
|
||||||
"house": ("https://open.spotify.com/playlist/2XF8ye6O5xqzT4NSfIXdfJ", "30 1 * * *", False),
|
|
||||||
"lofi": ("https://open.spotify.com/playlist/0qnyF6TAmuT3U6hFH2fiF1", "0 2 * * *", False),
|
|
||||||
"y2k": ("https://open.spotify.com/playlist/0iaA7ZJS00aRxhMwZn2GCU", "30 2 * * *", False),
|
|
||||||
"weeb": ("https://open.spotify.com/playlist/0599AbpsKRjsp2rlJI8jhf", "0 3 * * *", False),
|
|
||||||
"shoegaze": ("https://open.spotify.com/playlist/3R4S3tmbVKjXQ1RPAmryC5", "30 3 * * *", False),
|
|
||||||
"bass": ("https://open.spotify.com/playlist/2zkF4S16efaMmmzdIOe5w2", "0 4 * * *", False),
|
|
||||||
"hiphop": ("https://open.spotify.com/playlist/42JxTtOLJ7gG1ybfyy7Vmi", "30 4 * * *", False),
|
|
||||||
"modular": ("https://open.spotify.com/playlist/2kOfP2EM8dzdMYC6fXvlLb", "0 5 * * *", False),
|
|
||||||
"hotdog": ("https://open.spotify.com/playlist/53HrOL0qw47G9ywYZz3kgX", "30 5 * * *", False),
|
|
||||||
"kpop": ("https://open.spotify.com/playlist/31e2V512TIqr5JIfgkyseo", "0 6 * * *", False),
|
|
||||||
"jungle": ("https://open.spotify.com/playlist/4LjLeXg6ElneQiTaIcAHhE", "30 6 * * *", False),
|
|
||||||
"goldenera": ("https://open.spotify.com/playlist/57vArhigysJfgwB6CY4VXR", "0 7 * * *", False),
|
|
||||||
"botanica": ("https://open.spotify.com/playlist/5KgQT9YWz3EqhIN4Jh69IU", "30 7 * * *", False),
|
|
||||||
"hardcore": ("https://open.spotify.com/playlist/1J1lbzlQKligzr2LWaq9Ex", "0 23 * * *", False),
|
|
||||||
"liked": ("https://open.spotify.com/playlist/4Z3qCYuU1sjNeNYO3Amzeo", "30 23 * * *", True),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def list_all(db: Session) -> list[Playlist]:
|
def list_all(db: Session) -> list[Playlist]:
|
||||||
return list(db.execute(select(Playlist).order_by(Playlist.name)).scalars())
|
return list(db.execute(select(Playlist).order_by(Playlist.name)).scalars())
|
||||||
|
|
||||||
@@ -88,6 +83,7 @@ def create(
|
|||||||
notes: str | None = None,
|
notes: str | None = None,
|
||||||
cron_expr: str | None = None,
|
cron_expr: str | None = None,
|
||||||
) -> Playlist:
|
) -> Playlist:
|
||||||
|
name = validate_name(name)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
playlist = Playlist(
|
playlist = Playlist(
|
||||||
name=name,
|
name=name,
|
||||||
@@ -141,13 +137,33 @@ def delete(db: Session, playlist_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def seed_legacy(db: Session) -> int:
|
def seed_legacy(db: Session) -> int:
|
||||||
"""One-time import of the legacy hardcoded array (migration Stage 1/2).
|
"""One-time import of playlists from an optional legacy-playlists.json in
|
||||||
Skips any name that already exists. Returns the number of rows created."""
|
the config dir (migration helper). This is not personal data baked into
|
||||||
|
the code: point it at your own export. Each entry is an object with
|
||||||
|
"name", "spotify_url", and optional "cron_expr" and "no_m3u". Skips any
|
||||||
|
name that already exists. Returns the number of rows created, or 0 if the
|
||||||
|
file is absent.
|
||||||
|
|
||||||
|
Example legacy-playlists.json:
|
||||||
|
[{"name": "techno", "spotify_url": "https://open.spotify.com/playlist/...",
|
||||||
|
"cron_expr": "0 1 * * *", "no_m3u": false}]
|
||||||
|
"""
|
||||||
|
legacy_path = settings.pipeline_config_dir / "legacy-playlists.json"
|
||||||
|
if not legacy_path.exists():
|
||||||
|
return 0
|
||||||
|
entries = json.loads(legacy_path.read_text())
|
||||||
created = 0
|
created = 0
|
||||||
for name, (url, cron_expr, no_m3u) in LEGACY_PLAYLISTS.items():
|
for entry in entries:
|
||||||
|
name = entry["name"]
|
||||||
if get_by_name(db, name) is not None:
|
if get_by_name(db, name) is not None:
|
||||||
continue
|
continue
|
||||||
create(db, name=name, spotify_url=url, no_m3u=no_m3u, cron_expr=cron_expr)
|
create(
|
||||||
|
db,
|
||||||
|
name=name,
|
||||||
|
spotify_url=entry["spotify_url"],
|
||||||
|
no_m3u=entry.get("no_m3u", False),
|
||||||
|
cron_expr=entry.get("cron_expr"),
|
||||||
|
)
|
||||||
created += 1
|
created += 1
|
||||||
return created
|
return created
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
@@ -9,7 +11,22 @@ from app.models import Playlist, ScheduledJob
|
|||||||
from app.services import dedup_review_service, genre_review_service, pipeline_runner
|
from app.services import dedup_review_service, genre_review_service, pipeline_runner
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
||||||
TIMEZONE = "America/Edmonton"
|
log = logging.getLogger("alembic")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_timezone(name: str) -> str:
|
||||||
|
"""Validate the configured timezone, falling back to UTC if it isn't a
|
||||||
|
real IANA zone. Returned as a string because that's what APScheduler and
|
||||||
|
CronTrigger accept directly."""
|
||||||
|
try:
|
||||||
|
ZoneInfo(name)
|
||||||
|
return name
|
||||||
|
except (ZoneInfoNotFoundError, ValueError):
|
||||||
|
log.warning("Unknown timezone %r, falling back to UTC.", name)
|
||||||
|
return "UTC"
|
||||||
|
|
||||||
|
|
||||||
|
TIMEZONE = _resolve_timezone(settings.timezone)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Maintenance jobs, ported 1:1 from /etc/cron.d/sldl-maintenance. Each entry
|
# Maintenance jobs, ported 1:1 from /etc/cron.d/sldl-maintenance. Each entry
|
||||||
|
|||||||
+16
-1
@@ -1,5 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import AliasChoices, Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@@ -21,7 +22,11 @@ class Settings(BaseSettings):
|
|||||||
encryption_key_file: Path = Path("/run/secrets/alembic_master_key")
|
encryption_key_file: Path = Path("/run/secrets/alembic_master_key")
|
||||||
|
|
||||||
# Session cookie signing secret for Starlette's SessionMiddleware.
|
# Session cookie signing secret for Starlette's SessionMiddleware.
|
||||||
session_secret: str = "changeme-dev-only-override-in-production"
|
# Left empty by default on purpose: an empty/placeholder value would let
|
||||||
|
# anyone forge a signed session cookie and skip login entirely. When this
|
||||||
|
# is unset, resolve_session_secret() generates and persists a random one
|
||||||
|
# to the config volume instead of falling back to a known constant.
|
||||||
|
session_secret: str = ""
|
||||||
|
|
||||||
# Pocket ID OIDC client, registered once in the Pocket ID admin UI
|
# Pocket ID OIDC client, registered once in the Pocket ID admin UI
|
||||||
# (redirect URI: https://<alembic-host>/auth/callback).
|
# (redirect URI: https://<alembic-host>/auth/callback).
|
||||||
@@ -33,6 +38,16 @@ class Settings(BaseSettings):
|
|||||||
# may complete login even if Pocket ID ever grows a second account.
|
# may complete login even if Pocket ID ever grows a second account.
|
||||||
allowed_email: str = ""
|
allowed_email: str = ""
|
||||||
|
|
||||||
|
# IANA timezone (e.g. "America/Edmonton") used for all scheduling and for
|
||||||
|
# times shown in the UI and the status report. Defaults to UTC so a fresh
|
||||||
|
# install behaves predictably anywhere. Reads the standard Docker `TZ`
|
||||||
|
# variable, or `ALEMBIC_TIMEZONE` if you'd rather be explicit. An
|
||||||
|
# unrecognized value falls back to UTC (see scheduler_service).
|
||||||
|
timezone: str = Field(
|
||||||
|
default="UTC",
|
||||||
|
validation_alias=AliasChoices("TZ", "ALEMBIC_TIMEZONE", "TIMEZONE"),
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def beets_dir(self) -> Path:
|
def beets_dir(self) -> Path:
|
||||||
return self.alembic_config_dir / "beets"
|
return self.alembic_config_dir / "beets"
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
<h2 style="margin-top:0;">Credentials</h2>
|
<h2 style="margin-top:0;">Credentials</h2>
|
||||||
<p class="muted" style="margin-top:-0.75rem;">Write-only: values are encrypted at rest and never sent back to the browser. Leave a field blank to keep it unchanged.</p>
|
<p class="muted" style="margin-top:-0.75rem;">Write-only: values are encrypted at rest and never sent back to the browser. Leave a field blank to keep it unchanged.</p>
|
||||||
|
|
||||||
|
{% if not key_present %}
|
||||||
|
<div class="panel" style="border:1px solid #b91c1c;margin-bottom:1.5rem;">
|
||||||
|
<strong>No encryption key found.</strong>
|
||||||
|
<p class="muted" style="margin:0.5rem 0 0;">Credentials cannot be saved until the master key is in place. Generate one with <code>openssl rand -base64 32</code> and mount it as the <code>alembic_master_key</code> secret, then restart. See the <a href="/setup">setup check</a>.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="cred-grid">
|
<div class="cred-grid">
|
||||||
{% for scope, fields in scope_fields.items() %}
|
{% for scope, fields in scope_fields.items() %}
|
||||||
{% set is_core = scope in core_scopes %}
|
{% set is_core = scope in core_scopes %}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Dashboard — alembic{% endblock %}
|
{% block title %}Dashboard — alembic{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% if setup_warnings %}
|
||||||
|
<div class="panel" style="border:1px solid #b91c1c;margin-bottom:1.5rem;">
|
||||||
|
<strong>Setup needs attention</strong>
|
||||||
|
<ul class="muted" style="margin:0.5rem 0 0;padding-left:1.2rem;">
|
||||||
|
{% for c in setup_warnings %}<li>{{ c.detail }}</li>{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p class="muted" style="margin:0.5rem 0 0;">Full checklist on the <a href="/setup">setup page</a>.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Welcome back, {{ user.name or user.email }}</h1>
|
<h1>Welcome back, {{ user.name or user.email }}</h1>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Setup check · alembic{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="container" style="max-width:760px;margin:2rem auto;">
|
||||||
|
<div class="panel">
|
||||||
|
<h1 style="margin-top:0;">Setup check</h1>
|
||||||
|
{% if status == "ok" %}
|
||||||
|
<p class="muted">Everything needed to run is configured. You can log in from the
|
||||||
|
<a href="/">dashboard</a>.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Something needed to run is missing. Fix the items marked below,
|
||||||
|
then reload this page. You do not need to be logged in to see this.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<ul style="list-style:none;padding:0;margin:1.5rem 0 0;">
|
||||||
|
{% for c in checks %}
|
||||||
|
<li style="display:flex;gap:0.75rem;align-items:flex-start;padding:0.75rem 0;border-top:1px solid rgba(255,255,255,0.08);">
|
||||||
|
<span style="flex:0 0 auto;margin-top:0.15rem;">
|
||||||
|
{% if c.ok and c.get("warn") %}
|
||||||
|
<span class="badge badge-info" title="advisory">note</span>
|
||||||
|
{% elif c.ok %}
|
||||||
|
<span class="badge badge-success" title="ok">ok</span>
|
||||||
|
{% elif c.critical %}
|
||||||
|
<span class="badge" style="background:#b91c1c;color:#fff;" title="must fix">needs setup</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-muted" title="optional">optional</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>{{ c.key.replace("_", " ") }}</strong><br>
|
||||||
|
<span class="muted">{{ c.detail }}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
+34
-35
@@ -1,52 +1,51 @@
|
|||||||
# Paste this block into /home/andrew/booksandtunes/docker-compose.yml.
|
# Optional: route Soulseek traffic through a VPN container (gluetun).
|
||||||
#
|
#
|
||||||
# Also required, one-time, in that same file:
|
# This is a reference, not a drop-in file. Most people can ignore it and use
|
||||||
# 1. gluetun's environment: add/extend FIREWALL_OUTBOUND_SUBNETS to include
|
# the plain `ports:` mapping shown in the README. Use this pattern only if you
|
||||||
# the bridge network subnet (default 172.19.0.0/16 in this compose
|
# want alembic's outbound traffic to leave through a VPN.
|
||||||
# project — confirm with `docker network inspect booksandtunes_default`)
|
|
||||||
# so alembic can still reach navidrome:4533 etc:
|
|
||||||
# FIREWALL_OUTBOUND_SUBNETS=192.168.0.0/24,172.19.0.0/16
|
|
||||||
# 2. gluetun's ports: add the alembic web port (default 8420, verify free
|
|
||||||
# on the host first):
|
|
||||||
# - 8420:8420
|
|
||||||
# 3. Pin the bridge subnet so it can't silently drift on network recreate:
|
|
||||||
# networks:
|
|
||||||
# default:
|
|
||||||
# ipam:
|
|
||||||
# config:
|
|
||||||
# - subnet: 172.19.0.0/16
|
|
||||||
# 4. Remove the old on-demand `sldl:` service block entirely (superseded by
|
|
||||||
# the vendored binary running as an alembic subprocess) once cutover to
|
|
||||||
# alembic scheduling is complete (Migration Stage 3 in docs/MIGRATION.md).
|
|
||||||
# 5. Remove the `soulbeet:` service block once alembic has feature parity
|
|
||||||
# (Migration Stage 4).
|
|
||||||
#
|
#
|
||||||
# Pangolin: point the new route at gluetun:8420, NOT alembic:8420 — alembic
|
# The idea: instead of giving alembic its own network, you attach it to a
|
||||||
# has no independent container DNS identity once it rides gluetun's netns.
|
# gluetun container's network with `network_mode: "service:gluetun"`. All of
|
||||||
|
# alembic's traffic then exits through the VPN gluetun maintains.
|
||||||
|
#
|
||||||
|
# When you do this, three things change compared to the README example:
|
||||||
|
# 1. alembic no longer publishes its own port. Publish 8420 on the gluetun
|
||||||
|
# service instead (add `- 8420:8420` to gluetun's `ports:`).
|
||||||
|
# 2. gluetun's firewall must allow alembic to reach your other containers
|
||||||
|
# (Navidrome, etc). Add your Docker bridge subnet to gluetun's
|
||||||
|
# FIREWALL_OUTBOUND_SUBNETS, for example:
|
||||||
|
# FIREWALL_OUTBOUND_SUBNETS=192.168.0.0/24,172.18.0.0/16
|
||||||
|
# Find your subnet with: docker network inspect <your-compose-project>_default
|
||||||
|
# 3. If you use a reverse proxy, point it at gluetun's host/port, not
|
||||||
|
# alembic's. Once alembic shares gluetun's network it has no container
|
||||||
|
# hostname of its own.
|
||||||
|
|
||||||
alembic:
|
alembic:
|
||||||
image: git.kretzer.club/andrew/alembic:latest
|
image: alembic:latest
|
||||||
container_name: alembic
|
container_name: alembic
|
||||||
network_mode: "service:gluetun"
|
network_mode: "service:gluetun"
|
||||||
depends_on:
|
depends_on:
|
||||||
gluetun:
|
gluetun:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
labels:
|
env_file:
|
||||||
# Manual pull/up only — an auto-recreate mid-scheduled-job is exactly
|
# Your OIDC settings (POCKETID_ISSUER, POCKETID_CLIENT_ID,
|
||||||
# the kind of surprise this project's "no unnecessary automation"
|
# POCKETID_CLIENT_SECRET) and any other env vars.
|
||||||
# preference wants to avoid, and it would also orphan the
|
- /path/to/alembic-config/alembic.env
|
||||||
# network_mode: service:gluetun reference the same way gluetun's own
|
|
||||||
# watchtower exclusion comment (above, in the gluetun service) explains.
|
|
||||||
- "com.centurylinklabs.watchtower.enable=false"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ${MUSIC_DATA_DIR:-/mnt/qnap/Music}:/data/music:rw
|
- /path/to/your/music:/data/music:rw
|
||||||
- ${ALEMBIC_CONFIG_DIR:-/configs/alembicconfig}:/config:rw
|
- /path/to/alembic-config:/config:rw
|
||||||
secrets:
|
secrets:
|
||||||
- alembic_master_key
|
- alembic_master_key
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Add alongside the existing top-level `secrets:` block (create one if this
|
# Add alongside the existing top-level `secrets:` block (create one if this
|
||||||
# compose file doesn't have one yet):
|
# compose file doesn't have one yet).
|
||||||
|
#
|
||||||
|
# Keep master.key OUTSIDE the config volume. The key decrypts every stored
|
||||||
|
# credential, and the encrypted database lives in the config volume, so if the
|
||||||
|
# key sits in there too then a single leaked backup exposes both halves and the
|
||||||
|
# encryption buys you nothing. Put it in its own directory that your config
|
||||||
|
# backup does not include:
|
||||||
# secrets:
|
# secrets:
|
||||||
# alembic_master_key:
|
# alembic_master_key:
|
||||||
# file: /configs/alembicconfig/master.key
|
# file: /path/to/alembic-secrets/master.key
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
This is a short tour of how alembic is put together, for anyone who wants to
|
||||||
|
understand or modify it. If you only want to run it, the README is enough.
|
||||||
|
|
||||||
|
## One container, everything inside
|
||||||
|
|
||||||
|
alembic ships as a single Docker image. The image is code only. It holds no
|
||||||
|
playlists, no credentials, and no music. Everything that changes over time
|
||||||
|
lives in mounted folders, so you can rebuild or upgrade the image without
|
||||||
|
touching your data.
|
||||||
|
|
||||||
|
There are two data mounts, plus one small secret:
|
||||||
|
|
||||||
|
- `/data/music` is your library. alembic writes tagged files here in a normal
|
||||||
|
`Artist/Album/Title.ext` layout that Navidrome (or any folder-based music
|
||||||
|
server) can read directly.
|
||||||
|
- `/config` holds everything else: the app database, the beets database, the
|
||||||
|
rendered pipeline config and credential files, and job logs.
|
||||||
|
- `master.key` is mounted as a Docker secret at `/run/secrets/alembic_master_key`.
|
||||||
|
It is the key that encrypts your saved credentials, and it is kept separate
|
||||||
|
from `/config` on purpose (see the README on why).
|
||||||
|
|
||||||
|
## The pieces
|
||||||
|
|
||||||
|
- **The web app** is FastAPI. It serves the UI, handles login through your
|
||||||
|
OIDC provider, and owns all the settings. It reads and writes two SQLite
|
||||||
|
databases: its own (`/config/alembic.db`, playlists, jobs, encrypted
|
||||||
|
credentials, dedup and genre review state) and the beets library database.
|
||||||
|
- **The scheduler** is APScheduler, running in the same process as the web app.
|
||||||
|
It fires playlist syncs and maintenance jobs on their cron schedules. It
|
||||||
|
keeps its schedule in memory and rebuilds it from code plus the database on
|
||||||
|
every startup, so there is no separate scheduler process or job store to
|
||||||
|
manage.
|
||||||
|
- **The pipeline** is a set of shell and Python scripts under `pipeline/`. The
|
||||||
|
app runs them as subprocesses. They do the actual downloading, tagging,
|
||||||
|
importing, deduplicating, and so on. `sldl` (the Soulseek downloader) is
|
||||||
|
vendored in the image and also run as a subprocess.
|
||||||
|
|
||||||
|
A single lock serializes pipeline work. Only one pipeline job runs at a time;
|
||||||
|
if a second is triggered while one is running, it is recorded as skipped rather
|
||||||
|
than run concurrently. This mirrors the old single-lock behavior and avoids two
|
||||||
|
jobs fighting over the same files.
|
||||||
|
|
||||||
|
## What happens on a playlist sync
|
||||||
|
|
||||||
|
1. The app writes the current playlist list to `/config/pipeline/playlists.json`
|
||||||
|
and renders one sldl config file per playlist from a template. Your Spotify
|
||||||
|
and Soulseek credentials are patched into those config files, decrypted from
|
||||||
|
the database just before use.
|
||||||
|
2. `sldl` reads a playlist's config, fetches the tracklist from Spotify, and
|
||||||
|
downloads each track from Soulseek into a per-playlist dropbox folder.
|
||||||
|
3. The downloaded files are tagged. Spotify is treated as the source of truth
|
||||||
|
for artist, title, album, and genre.
|
||||||
|
4. beets imports the tagged files, moving them into `/data/music` in the
|
||||||
|
canonical folder layout.
|
||||||
|
5. A matching `.m3u8` playlist file is written so the playlist plays back in
|
||||||
|
order.
|
||||||
|
6. Navidrome is asked to rescan so the new tracks show up.
|
||||||
|
|
||||||
|
Every run gets a row in the job history and its own log file, both visible in
|
||||||
|
the UI under Settings then Jobs.
|
||||||
|
|
||||||
|
## A note on the name
|
||||||
|
|
||||||
|
"alembic" here is this project. It is not the Python database-migration tool
|
||||||
|
also called Alembic. This project uses plain SQL files under `schema/` for its
|
||||||
|
own tiny migrations and does not depend on that tool. The name is a nod to a
|
||||||
|
still, which distills a raw input into something refined, which is roughly what
|
||||||
|
this does to a Spotify playlist.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Migrating from the legacy scripts
|
||||||
|
|
||||||
|
This is only for people moving from the earlier version of this project, when
|
||||||
|
it was a collection of scripts under `/opt/sldl` with credentials scattered in
|
||||||
|
separate config folders. If you are setting up alembic fresh, ignore this file
|
||||||
|
and follow the README.
|
||||||
|
|
||||||
|
The migration does two things: bring your old playlists in, and bring your old
|
||||||
|
credentials in. Order matters, so do them in this order.
|
||||||
|
|
||||||
|
## 1. Import your playlists
|
||||||
|
|
||||||
|
`seed_legacy()` reads a JSON file at `/config/pipeline/legacy-playlists.json`
|
||||||
|
and creates a playlist row for each entry. The file is not shipped with your
|
||||||
|
playlists in it; you provide it. There is an example to copy from at
|
||||||
|
`pipeline/configs/legacy-playlists.json.example`.
|
||||||
|
|
||||||
|
Each entry looks like this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "techno",
|
||||||
|
"spotify_url": "https://open.spotify.com/playlist/your-id-here",
|
||||||
|
"cron_expr": "0 1 * * *",
|
||||||
|
"no_m3u": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Put your file at `/config/pipeline/legacy-playlists.json`, then run the seed
|
||||||
|
once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec alembic python -c "from app.db import SessionLocal; from app.services import playlist_service; db=SessionLocal(); print('created', playlist_service.seed_legacy(db)); db.close()"
|
||||||
|
```
|
||||||
|
|
||||||
|
It is safe to run more than once. It skips any playlist name that already
|
||||||
|
exists.
|
||||||
|
|
||||||
|
## 2. Import your credentials
|
||||||
|
|
||||||
|
Do this after step 1, not before. The credential import re-renders every
|
||||||
|
existing playlist config file with your Soulseek and Spotify details. If no
|
||||||
|
playlists exist yet, that render has nothing to write to and the step quietly
|
||||||
|
does nothing.
|
||||||
|
|
||||||
|
`scripts/import_legacy_credentials.py` reads your old plaintext credentials and
|
||||||
|
saves them into alembic's encrypted store. It only reads the legacy files, it
|
||||||
|
never changes them, and it is safe to run more than once.
|
||||||
|
|
||||||
|
It expects your old config folders bind-mounted read-only at `/legacy/*`. Run
|
||||||
|
it as a one-off container against the same image and config volume, for example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
-v /path/to/alembic-config:/config \
|
||||||
|
-v /path/to/old/opt-sldl:/legacy/opt-sldl:ro \
|
||||||
|
-v /path/to/old/bandcampconfig:/legacy/bandcampconfig:ro \
|
||||||
|
-v /path/to/old/azuracastconfig:/legacy/azuracastconfig:ro \
|
||||||
|
-v /path/to/old/qobuzconfig:/legacy/qobuzconfig:ro \
|
||||||
|
-v /path/to/old/telegramconfig:/legacy/telegramconfig:ro \
|
||||||
|
--secret ... \
|
||||||
|
--entrypoint python \
|
||||||
|
alembic:latest /app/scripts/import_legacy_credentials.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave off any `-v /legacy/...` mount for a service you never used. The importer
|
||||||
|
skips a legacy folder that is not present.
|
||||||
|
|
||||||
|
## 3. Retire the old services
|
||||||
|
|
||||||
|
Once alembic is running your syncs and you have confirmed a playlist downloads
|
||||||
|
and imports correctly, you can remove the old on-demand download service and
|
||||||
|
the old import service from your compose file. Keep your old config folders
|
||||||
|
backed up until you are confident, since the importer only reads them.
|
||||||
+12
-2
@@ -7,10 +7,19 @@ set -euo pipefail
|
|||||||
ALEMBIC_CONFIG_DIR="${ALEMBIC_CONFIG_DIR:-/config}"
|
ALEMBIC_CONFIG_DIR="${ALEMBIC_CONFIG_DIR:-/config}"
|
||||||
PIPELINE_DIR="${PIPELINE_DIR:-/app/pipeline}"
|
PIPELINE_DIR="${PIPELINE_DIR:-/app/pipeline}"
|
||||||
|
|
||||||
mkdir -p \
|
# The container runs as uid 1000. If the mounted config folder is owned by
|
||||||
|
# root on the host (the usual result of `mkdir` as your normal user), the
|
||||||
|
# first write fails and the container would otherwise exit with a bare
|
||||||
|
# "Permission denied". Catch that here and say exactly how to fix it.
|
||||||
|
if ! mkdir -p \
|
||||||
"$ALEMBIC_CONFIG_DIR/beets/data" \
|
"$ALEMBIC_CONFIG_DIR/beets/data" \
|
||||||
"$ALEMBIC_CONFIG_DIR/pipeline" \
|
"$ALEMBIC_CONFIG_DIR/pipeline" \
|
||||||
"$ALEMBIC_CONFIG_DIR/logs"
|
"$ALEMBIC_CONFIG_DIR/logs" 2>/dev/null; then
|
||||||
|
echo "[entrypoint] ERROR: cannot write to $ALEMBIC_CONFIG_DIR" >&2
|
||||||
|
echo "[entrypoint] The container runs as uid 1000 but that folder is not writable by it." >&2
|
||||||
|
echo "[entrypoint] On the host, run: chown -R 1000:1000 <the folder mounted at /config>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
seed_if_absent() {
|
seed_if_absent() {
|
||||||
local src="$1" dst="$2"
|
local src="$1" dst="$2"
|
||||||
@@ -24,6 +33,7 @@ seed_if_absent "$PIPELINE_DIR/beets/config.yaml.example" "$ALEMBIC_CONFIG_DIR/be
|
|||||||
seed_if_absent "$PIPELINE_DIR/configs/artist-canonical.list.example" "$ALEMBIC_CONFIG_DIR/pipeline/artist-canonical.list"
|
seed_if_absent "$PIPELINE_DIR/configs/artist-canonical.list.example" "$ALEMBIC_CONFIG_DIR/pipeline/artist-canonical.list"
|
||||||
seed_if_absent "$PIPELINE_DIR/configs/djmix-albums.txt.example" "$ALEMBIC_CONFIG_DIR/pipeline/djmix-albums.txt"
|
seed_if_absent "$PIPELINE_DIR/configs/djmix-albums.txt.example" "$ALEMBIC_CONFIG_DIR/pipeline/djmix-albums.txt"
|
||||||
seed_if_absent "$PIPELINE_DIR/configs/genres-whitelist.txt.example" "$ALEMBIC_CONFIG_DIR/pipeline/genres-whitelist.txt"
|
seed_if_absent "$PIPELINE_DIR/configs/genres-whitelist.txt.example" "$ALEMBIC_CONFIG_DIR/pipeline/genres-whitelist.txt"
|
||||||
|
seed_if_absent "$PIPELINE_DIR/configs/cross-album-keep.list.example" "$ALEMBIC_CONFIG_DIR/pipeline/cross-album-keep.list"
|
||||||
|
|
||||||
export BEETSDIR="$ALEMBIC_CONFIG_DIR/beets"
|
export BEETSDIR="$ALEMBIC_CONFIG_DIR/beets"
|
||||||
|
|
||||||
|
|||||||
@@ -1,413 +0,0 @@
|
|||||||
Artist,Title,Album,Length
|
|
||||||
,,,508:29
|
|
||||||
32ki; Hatsune Miku; 重音テト,メズマライザー (feat. 初音ミク&重音テト),メズマライザー,2:37
|
|
||||||
33 Below; DRIIA,LOYAL,LOYAL,4:15
|
|
||||||
[KRTM]; TWAN,Black Widow,Gum EP,6:21
|
|
||||||
\berhaupt & Au_erdem,Late Breakfast (Original),"Badarchin, Vol. 04",7:08
|
|
||||||
a r u k a.; DOVVE; echoed.; flawguri; Stardew,Aquarius,STORYBOOK: Chapter 2,5:09
|
|
||||||
A*S*Y*S,Bassface,Bassface EP,6:30
|
|
||||||
Above & Beyond/Oceanlab,Satellite,Super8 & Tab Presents: 20 Years Of Anjunabeats,3:29
|
|
||||||
Above & Beyond; Marty Longstaff,Flying By Candlelight - Above & Beyond Club Mix,Flying By Candlelight,4:02
|
|
||||||
Above & Beyond; Richard Bedford,Northern Soul,Common Ground,5:35
|
|
||||||
Adam Beyer; Kölsch,What You Need - Kölsch Remix,What You Need (Kölsch Remix),6:40
|
|
||||||
Aiko , ETHERIA,TECHNIKA [memory system],TECHNIKA [memory system]
|
|
||||||
AIROD,Universe of 90's Techno Parties [Molekul 007],Universe of 90's Techno Parties [Molekul 007],7:56
|
|
||||||
Alaskan Tapes,All We Can't See,All We Can't See / An Image,1:56
|
|
||||||
Alegant/Tube & Berger,Get Down (Short Edit),False Gods EP (Short Edits),3:34
|
|
||||||
Alesso; Tove Lo,Heroes (we could be),Forever,3:31
|
|
||||||
Alex Luciano,Under the Rain,Under the Rain,3:35
|
|
||||||
Alex Preston,Love You Better - Extended Mix,Love You Better,5:29
|
|
||||||
Alignment,Interference - Luca Agnelli Re,Interference,7:18
|
|
||||||
Alignment; Regal,Interference - Regal Remix,Interference,6:24
|
|
||||||
Alphazone,Alphazone Sunrise - Original C,Alphazone Sunrise,7:08
|
|
||||||
alt-J; Danny Brown; The Alchemist,Deadcrush (feat. Danny Brown) - Alchemist x Trooko Version,Deadcrush (feat. Danny Brown) [Alchemist x Trooko Version],4:03
|
|
||||||
altrice; Blue Dust Archive,bda creature,compciter ep,4:53
|
|
||||||
Amos & Riot Night; Ren Faye,Different View,Different View,4:13
|
|
||||||
Amos & Riot Night; Ren Faye,Different View,Different View,8:42
|
|
||||||
Amy Axegale,In Your Section,In Your Section,5:33
|
|
||||||
Andain; Myon; Shane 54,Promises - Myon & Shane 54 Sum,Promises,3:48
|
|
||||||
Andrea Kaeri/Phonez/BEKIMACHINE,Nothing for You,Dreamlike,5:26
|
|
||||||
Anetha; Sugar,Candy from Strangers,Don't Rush to Grow Up,8:07
|
|
||||||
ANNA; Miss Kittin,Forever Ravers,Speicher 112,6:00
|
|
||||||
Anton Kling,Mareld,Mareld,7:32
|
|
||||||
Apashe; VOLAC,Distance - Volac Remix,Distance (Volac Remix),3:35
|
|
||||||
Armin van Buuren; Tempo Giusto; i_o,Mr. Navigator - i_o Remix,"Balance (Remixes, Pt. 1)",5:54
|
|
||||||
ARTY; Brina Knauss,Hope - Brina Knauss Remix,Hope (Brina Knauss Remix),5:58
|
|
||||||
ARXMANE,'98 RACE,'98 RACE,1:40
|
|
||||||
Asagaoaudio,ring and portrait,the next game EP,5:52
|
|
||||||
At Dawn,Bare Essentials (Vip Edit),Routine Espresso 002: Cafe Dreams,5:36
|
|
||||||
Avicii,Silhouettes - Original Mix,Silhouettes,7:00
|
|
||||||
Avicii; Sandro Cavazza,Without You (feat. Sandro Cavazza),AVĪCI (01),2:58
|
|
||||||
Baalti,Aame,Baalti,2:28
|
|
||||||
Baalti,Ustad,Thandai - Flavours of the East,6:11
|
|
||||||
BABii; Pholo,EMBER (crashed) (crashed)_PHOLO,EMBER (crashed) (crashed)_PHOLO,3:46
|
|
||||||
Baby Billy Freeman,Bible Bonkers (Theme Song),The Righteous Gemstones: Season 3 (HBO Original Series Soundtrack),0:48
|
|
||||||
Baile,Dazed,Siren,6:46
|
|
||||||
Barry Can't Swim; somedeadbeat,Deadbeat Gospel,When Will We Land?,4:14
|
|
||||||
Basstakil,What If We Are Not Alone,What If We Are Not Alone,6:30
|
|
||||||
Beatenberg,The Lighthouse of Alexandria,On the way to Beatenberg,2:39
|
|
||||||
Belljack,불행한,Searching Within,6:03
|
|
||||||
Benny Bridges,Lucky Penny (Black Loops Remix),Lucky Penny (Black Loops Remix),5:33
|
|
||||||
biskuwi,Veil,Two Minds,5:23
|
|
||||||
Bjorn Salvador/Middle Aged Dad,Steina,Steina,8:08
|
|
||||||
Black Eyed Peas,Pump It (GWE,Pump It (GWE - Single,5:31
|
|
||||||
Bladee,Be Nice 2 Me (Euphorizer Remix),Be Nice 2 Me (Euphorizer Remix) - Single,3:21
|
|
||||||
BLCKLGHT,Away From You,Away From You,4:21
|
|
||||||
Bodysync; Ryan Hemsworth; Giraffage,Rhythm,Rhythm,2:32
|
|
||||||
Bomat,Doing All Right,Doing All Right,7:17
|
|
||||||
Boris Brejcha,Lieblingsmensch - Edit,Lieblingsmensch (Edit),3:40
|
|
||||||
Boris Brejcha,Never Look Back - Edit,Never Look Back (Edit),3:25
|
|
||||||
Boris Brejcha; Ginger,Happinezz - Edit,Happinezz (Edit),4:05
|
|
||||||
Bou; Trigga,Veteran,Scorpio,4:29
|
|
||||||
Brendan Park,Relic 19,Melt EP,4:33
|
|
||||||
Brooks Aleksander,You,You,4:11
|
|
||||||
Bruce Hornsby,5.Song C - Instrumental,Spirit Trail,2:47
|
|
||||||
Bug; Boyskin,Wait,Wait,6:31
|
|
||||||
Buku,TiiGA,TiiGA,3:48
|
|
||||||
BUSDRIVER; Aesop Rock; Danny Brown,Ego Death,Perfect Hair,6:00
|
|
||||||
Catz 'n Dogz; Jono McCleery,Would You Believe,Friendship,4:30
|
|
||||||
CBDJ,towards you,towards you,6:08
|
|
||||||
Chambray,Anew,Work That,7:42
|
|
||||||
Chance the Rapper; Future,Smoke Break (feat. Future),Coloring Book,3:46
|
|
||||||
CHANEY,Blank Space,Blank Space,5:17
|
|
||||||
Chicago Loop,Supernova 303,Memories Made / Supernova 303,3:36
|
|
||||||
Chris Luno,Better Try Again,Better Try Again,5:18
|
|
||||||
Chris Luno/Beron,The Hike,The Hike,7:46
|
|
||||||
Chris Luno/Beron,Friday,The Hike,6:43
|
|
||||||
Chris Luno/Beron,Saturday,The Hike,7:37
|
|
||||||
CJ Bolland; The Advent; Enrico Sangiuliano,Camargue 2019 - Enrico Sangiuliano Remix,Camargue 2019,8:14
|
|
||||||
Clams Casino; Vince Staples,All Nite (feat. Vince Staples),32 Levels (Deluxe),2:47
|
|
||||||
clipping.; Cocc Pistol Cree,Work Work,CLPPNG,3:43
|
|
||||||
Coastal,New Life,Cohesion,2:21
|
|
||||||
Cosmo's Midnight; Sarah Bonito,Hurt (feat. Sarah Bonito),Moments - EP,3:30
|
|
||||||
Crimsen/Feyln/Oliver Wickham,Grey Skies (Gorge Remix),Grey Skies (Gorge Remix),6:47
|
|
||||||
Cynthoni; Sewerslvt; AgonyOST,"Quitting Is Hard, Not Quitting Is Harder",SMOKE IT TO THE BUTT,5:49
|
|
||||||
dUO (Sakura Fujiwara),DdK_ ;_Dk (Itsuka Mita Eiga Mitai Ni),AIRPORT,3:19
|
|
||||||
D-Jastic,Up To No Good - Extended Mix,Up To No Good (Extended Mix),5:11
|
|
||||||
D-Mad; ARTY,She Gave Happiness - Arty Remix,"Digital Society International, Volume Three - Mixed by Ashley Wallbridge & Activa",6:05
|
|
||||||
Daijiro Nakagawa,Concentration,Loop Lullaby,2:44
|
|
||||||
Dan be; Alex Bone; hej motti,Ooh - hej motti Remix,Ooh (hej motti Remix),2:58
|
|
||||||
Danny Brown; Kendrick Lamar; Ab-Soul; Earl Sweatshirt,Really Doe,Atrocity Exhibition,5:19
|
|
||||||
Danny Brown; Purity Ring,25 Bucks (feat. Purity Ring),Old,3:30
|
|
||||||
Daphni; Caribou,Waiting So Long,Waiting So Long,3:54
|
|
||||||
Darby; OOTORO,PARTY,PARTY,2:08
|
|
||||||
Darc Marc,Dirty Rocking Bass - Dirty Rocking Bassline,Dirty Rocking Bass (Dirty Rocking Bassline),7:48
|
|
||||||
dark cat; juu; Cinders,BUBBLE TEA,BUBBLE TEA,4:03
|
|
||||||
Dash Berlin; Emma Hewitt,Waiting,The New Daylight,9:28
|
|
||||||
Data_haven,Utopia - Reprise,Data_haven [re-constructed],5:23
|
|
||||||
Davey Asprey,Catch,Catch,3:20
|
|
||||||
David Guetta; Sia,Titanium (feat. Sia),Nothing but the Beat 2.0,4:05
|
|
||||||
David Guetta; USHER,Without You (feat. Usher),Nothing but the Beat 2.0,3:28
|
|
||||||
Deadmau5,The Veldt V Porter Robinson - Language,The Veldt V Porter Robinson - Language - Single,2:07
|
|
||||||
deadmau5; i_o,Imaginary Friends - i_o Remix,"We Are Friends, Vol. 8",6:16
|
|
||||||
deadmau5; Rinzen,Monophobia - Rinzen Remix,mau5ville: Level 1,6:41
|
|
||||||
Death Grips,Beware,Exmilitary,5:52
|
|
||||||
Deborah de Luca; Marika Rossa,In Hypnose,In Hypnose,6:35
|
|
||||||
Diamond Dallas Tex; Lil Texas,Work My Body,The Afters,3:35
|
|
||||||
Diffrent,A Little Closer (Extended)145,A Little Closer (Extended)145 - Single,5:18
|
|
||||||
Diveo,Ferris Wheel,Singles Club Executive Lounge: Celebrating 50 Years of Activia Benz,3:05
|
|
||||||
DJ BORING,Like Water - Edit,Like Water,6:40
|
|
||||||
DJ Dextro,Sideral,Sideral,6:11
|
|
||||||
dj ignorant,i don't want to lose you,i don't want to lose you,5:17
|
|
||||||
DJ Koze,"Pick Up - 12"" Extended Disco Version",Pick Up,10:01
|
|
||||||
DJ PP,Desert Night (Extended Mix),Desert Night,6:37
|
|
||||||
DJ Seinfeld; ARY,Of Joy,Everything U,3:19
|
|
||||||
DJ_Dave; kitty ray,Still Miss U,Still Miss U,3:47
|
|
||||||
doechii,nissan altima (wheth,nissan altima (wheth - Single,2:10
|
|
||||||
Double Touch/Reigan,Greatest Day,Greatest Day,3:49
|
|
||||||
DRT; Alex K,Take Me Away - Alex K Remix,Take Me Away,2:27
|
|
||||||
Drunken Kong,Realize,Repeat,7:27
|
|
||||||
dublon; Deza,Sweet Nothings,Sweet Nothings,2:22
|
|
||||||
Duke Hugh,Greenleaf,Canvas,4:29
|
|
||||||
DV-i,Genconfinal,Metronomicon Audio 6.0,1:00
|
|
||||||
Eclipse,24-7 - Original Mix,24-7,8:04
|
|
||||||
Ed Balloon; Open Mike Eagle; They Hate Change,Missed Call,Missed Call,3:55
|
|
||||||
Effy; Mall Grab,iluv,iluv,4:17
|
|
||||||
Ejeca,U Gave Me Love,U Gave Me Love,2:21
|
|
||||||
Eleni Foureira; ARCADE,Disco-Tech,Disco-Tech,3:19
|
|
||||||
EMBRZ,Don't Look Back,Don't Look Back,3:27
|
|
||||||
EMBRZ; Montgomery,Count To Three,Count To Three,3:18
|
|
||||||
Eric Prydz,Pjanoo - Club Mix,Pjanoo,7:30
|
|
||||||
Ettu Brute,Know,Concrete,3:15
|
|
||||||
Ezekiel,help_urself (breakcore),help_urself (Alternates),1:58
|
|
||||||
F-Rontal,Inner Souls,Inner Souls,6:58
|
|
||||||
FallenLights.,You Are Enough,For Her,1:51
|
|
||||||
Farkey/Hayedeh,Flashback,Flashback,3:13
|
|
||||||
Felix Cartal; Hanne Mjøen,My Last Song (Felix Cartal's After Hours Mix),My Last Song (Felix Cartal's After Hours Mix),3:58
|
|
||||||
FenixForce,Domestic Violence,Inexcusable,5:52
|
|
||||||
Fhase 87,Redemption,Redemption EP,5:24
|
|
||||||
Flying Lotus; Kendrick Lamar,Never Catch Me,You're Dead!,3:54
|
|
||||||
Flywright,Thinkin' Bout You Lately,Hush Your Fuss,2:42
|
|
||||||
Frankyeffe; Teenage Mutants,Polymath,Polymath,6:11
|
|
||||||
Fred again.. & Anderson .Paak & Chika,places to be (CLIPZ remix),places to be (remixes),3:04
|
|
||||||
Fred again..; Anderson .Paak; CHIKA; CLIPZ,places to be - CLIPZ remix,places to be (remixes),3:04
|
|
||||||
Fred again..; Baby Keem; Nia Archives,leavemealone - Nia Archives Remix,USB,2:59
|
|
||||||
FROOTS,Papaya,Papaya,3:12
|
|
||||||
Frost Children,Satellites,Tweaker Poem,3:31
|
|
||||||
Gabriel & Dresden; Sub Teal; C,Only Road - Cosmic Gate Remix,Only Road (Cosmic Gate Remix),3:45
|
|
||||||
Gensuke Kanki,Vertige,Durée,3:54
|
|
||||||
Gilles Bernies,Knowing,Knowing,6:01
|
|
||||||
Gilmer Galibard,Bougarabou,A Baker's Shit Ton,5:36
|
|
||||||
Glen Campbell,Wichita Lineman - Remastered 2001,Wichita Lineman (Remastered),3:06
|
|
||||||
Goncalo M,Infinite Black,Best Weapons,6:08
|
|
||||||
Good Times Ahead; Vince Staples,Little Bit of This (feat. Vince Staples),Good Times Ahead,2:40
|
|
||||||
Greg Gonzalez,Country of the Mind (Edit),Country Of The Mind,6:25
|
|
||||||
Hadone,How To Fake Success - Original mix,Defining Persuasion (Original),6:27
|
|
||||||
Hailaker; S. Carey,Wavepool,Wavepool,3:38
|
|
||||||
harmony haven; Phusis,Violet Flash,Healing Visions,2:48
|
|
||||||
Helios,Bless This Morning Year,Eingya (Remastered),6:01
|
|
||||||
HNNY,Noth...,Nothing,5:12
|
|
||||||
Hope Tala,Tiptoeing - Tommy Villiers Remix,Tiptoeing (Tommy Villiers Remix),3:08
|
|
||||||
Hypsidia/Altriparty,Viral (Notre Dame Remix),Viral (The Remixes),4:14
|
|
||||||
HYUKOH; 落日飛車 Sunset Rollercoaster,Aaaannnnteeeeennnaaaaaa,AAA,6:50
|
|
||||||
"i_o, Raito",Rivolution,rivolution,5:12
|
|
||||||
i_o; Lights,Annihilation,Annihilation,4:07
|
|
||||||
i_o; Raito,Come with Me,House of God,6:05
|
|
||||||
i_o; Raito,Don't Stop,révolution,5:24
|
|
||||||
Ian van Dahl; Marsha,Castles In The Sky (featuring,Castles In The Sky,6:46
|
|
||||||
Ilja Alexander,Someday (LCAW Remix),Someday (LCAW Remix),6:38
|
|
||||||
Illiya Korniyenko,Ithori - Original,The Collapse Of Future Vol.3,6:34
|
|
||||||
Inafekt; Big Jon,Blaze (Extended Mix),Blaze,5:35
|
|
||||||
Infeed; INNERGATED,Freedom Inside Your Illness - LS41 Remix,Resurrected in the Hell,5:00
|
|
||||||
Isambard Khroustaliov,Atoll Song,"This Is My Private Beach, This Is My Jetsam",6:43
|
|
||||||
ISOxo; Ninajirachi,SHYPOP,kidsgonemad!,3:28
|
|
||||||
J.Shore; Mango; Orion; Shingo Nakamura,Raining In Osaka - Shingo Nakamura Remix,Raining In Osaka,7:44
|
|
||||||
Jacana People,Imari,Imari,4:23
|
|
||||||
jahjaylee,"Right, Right. Right?",jah,6:43
|
|
||||||
Jake Kaiser,Kestrel,Winter Selections 06,3:21
|
|
||||||
jamesjamesjames,"It’s Not You, It’s Me - Club Mix","It's Not You, It's Me (Club Mix)",3:44
|
|
||||||
jamesjamesjames; snctm.,homesoon.,homesoon.,3:55
|
|
||||||
jamesjamesjames; Spring Breaker,ibiza is waiting for me,ibiza is waiting for me,5:27
|
|
||||||
Japano File,Holy.Place,Holy.Place,3:50
|
|
||||||
Jaques Raupé; Felix Harrer,3 Haselnüsse,3 Haselnüsse,2:28
|
|
||||||
Jason Ross; Fiora,Through It All,Through It All,3:12
|
|
||||||
Jauz; i_o,Truth,Truth,5:45
|
|
||||||
Jayda G,Both Of Us - Edit,Both Of Us,3:49
|
|
||||||
Jazzanova/Edward Vanzet,I'm Still Here (Winnie & Somow Remix),I'm Still Here - Best's Friends Remixes,6:03
|
|
||||||
Jend,Forever,Forever,2:38
|
|
||||||
Jensen Interceptor; Assembler Code,Runner,6th Element,5:17
|
|
||||||
Jeshi; Ross from Friends,3210 - Ross from Friends Remix,3210 (Ross from Friends Remix),5:52
|
|
||||||
jizue,photograph feat.中嶋イッキュウ,shiori,5:56
|
|
||||||
Johnny Jungle,Johnny '94 - Dillinja Remix,Johnny '94 (Dillinja And Noise Of Art & Double D Remixes),6:28
|
|
||||||
Joy Anonymous,JOY (Up The Street),Cult Classics,3:58
|
|
||||||
JUKO; robo; wishlane; Mistrrr; The Litehouse; Foli,The Guardian,STORYBOOK,4:56
|
|
||||||
Julius Abel,Lost,Lost,3:39
|
|
||||||
Kacy Hill,I Believe In You (feat. Francis and the Lights),Is It Selfish If We Talk About Me Again,0:29
|
|
||||||
KAISUI,Cyclone,Cyclone EP,4:13
|
|
||||||
Kay Cee,Escape - Club Mix,Escape,6:51
|
|
||||||
Kayestone,Atmosphere - Angelic Remix,Atmosphere,8:27
|
|
||||||
Keiju(JP),Only U Know,Only U Know,6:18
|
|
||||||
Kesha,Die Young (Lil Texas Edit),Die Young (Lil Texas Edit) - Single,2:15
|
|
||||||
Kevin Flum,Mansion Tears,Mansion Tears,2:20
|
|
||||||
kita kouhei,Botanical Specimens,Neospecies,2:58
|
|
||||||
Kito; Chrome Sparks; IS U IS U,Melter,Melter,4:14
|
|
||||||
Kloyd,Sur,Night Tales,5:34
|
|
||||||
KOAN Sound,Shimmer,Moments of Opening,4:00
|
|
||||||
KOAN Sound,Colour Dance,Moments of Opening,4:29
|
|
||||||
KOAN Sound,Allure,Moments of Opening,2:51
|
|
||||||
KOAN Sound,Phantom Sight,Moments of Opening,4:48
|
|
||||||
Kobe JT,Lately,Lately,3:52
|
|
||||||
Koriass,Zombies,Love suprême,3:38
|
|
||||||
Kumi Takahara; aus,Tide,Tide,6:02
|
|
||||||
Kwengface; Joy Orbison; Overmono,Freedom 2,Freedom 2,3:26
|
|
||||||
Kyau & Albert; Jeza,Bring You Back - Original Mix,Matching Stories,4:35
|
|
||||||
Kyuuwaii,sora,bedroom,2:46
|
|
||||||
Lana Del Rey; Cedric Gervais,Summertime Sadness (Lana Del Rey Vs. Cedric Gervais) - Cedric Gervais Remix,Summertime Sadness (Lana Del Rey Vs. Cedric Gervais) [Cedric Gervais Remix],3:34
|
|
||||||
Landhouse/Sima Aava,Tales from the Swallow (Pandhora Remix),Tales from the Swallow (Remixes),4:10
|
|
||||||
Lauren Mia,Colours,Colours,7:26
|
|
||||||
Le Youth; Lane 8; Jyll,I Will Leave a Light On,About Us,4:00
|
|
||||||
Les Petits Pilous,Russian Forest,Bielle,3:44
|
|
||||||
Lil Texas; Irradiate,Highs In My Dreams,Highs In My Dreams,2:26
|
|
||||||
Lilly Palmer,Shift,All Things Fall,6:52
|
|
||||||
"Local Nomad, Nihil Young & Paige/Local Nomad/Nihil Young/Paige",Gates (Paige & Nihil Young Remix) - Extended,Gates (Paige & Nihil Young Remix),5:33
|
|
||||||
Logic1000; yunè pinku,What You Like,What You Like (feat. yunè pinku),3:13
|
|
||||||
Longo; Marco Lollis,Love Again,Love Again,2:59
|
|
||||||
longstoryshort,HER,HER,2:36
|
|
||||||
Loods,Need U,Need U,3:22
|
|
||||||
Loods; KLP,Freedom,Freedom,2:54
|
|
||||||
Lord RAJA; Jeremiah Jae,Van Go (feat. Jeremiah Jae),A Constant Moth,2:04
|
|
||||||
Lorenzo Raganzini; Paolo Ferrara; OGUZ,Raving in Paris - Oguz Journey Mix,Raving in Paris (Oguz Journey Mix),11:18
|
|
||||||
Luciid,Mob,Mob / D.O.L.L.A,8:12
|
|
||||||
Luciid,D.O.L.L.A,Mob / D.O.L.L.A,6:38
|
|
||||||
Luisa Marion,The Kite (Campfire Session) [Live],The Kite (Campfire Session) [Live],3:55
|
|
||||||
LUV HRTS,CAN WE DO BETTER?,CAN WE DO BETTER?,3:07
|
|
||||||
LUV HRTS,GONNA GET THRU,GONNA GET THRU,3:20
|
|
||||||
"LUV HRTS, Mild Minds",U REALLY HURT ME,U REALLY HURT ME,2:42
|
|
||||||
LYNY; FLY,Pulse,Pulse,2:27
|
|
||||||
M Field,Andrew,Andrew,2:44
|
|
||||||
Madeon,GoodFaith Radio (Ep. 1),GoodFaith Radio,59:29
|
|
||||||
Mall Grab; Skin On Skin,Strangers,Strangers,6:09
|
|
||||||
"Mango,Orion, J.Shore",When All the Ships Are Gone (Album Mix],Citylanes Airplanes,9:30
|
|
||||||
Mannequin; prod. DTM,Lay It On Me,Lay It On Me,2:17
|
|
||||||
Manuel Di Martino,Impact,Alpha Sides Vol. IV,5:10
|
|
||||||
Mark Michael,Solar Storm - Original Mix,Solar Storm,4:48
|
|
||||||
Martin Solveig; Faouzia; TRYM,Now Or Never - TRYM Remix,Now Or Never (Remixes),5:17
|
|
||||||
Masakatsu Takagi,Marginalia #131,Marginalia #131,7:10
|
|
||||||
Masakatsu Takagi,Marginalia #139,Marginalia #139,2:22
|
|
||||||
Masakatsu Takagi,Marginalia #143,Marginalia #143,2:53
|
|
||||||
Masakatsu Takagi,Marginalia #153,Marginalia #153,2:53
|
|
||||||
Masakatsu Takagi; 坂本美雨,Field Fjord,sorato,1:00
|
|
||||||
Matteo Vitanza,Acid? Why Not?,We Overcome,6:57
|
|
||||||
Mees Javois,DSTOP,DSTOP,5:27
|
|
||||||
Melé,And 1 (Extended Mix),And 1 (Extended Mix) - Single,4:30
|
|
||||||
Michael Klein,Yuzu,Yuzu,6:47
|
|
||||||
Middle School,remember,remember,1:23
|
|
||||||
midnight; ylxr,Promethium Reprise,Lo-Fi_house.Zip,4:00
|
|
||||||
Mike Posner,I Took a Pill in Ibiza (SeeB r,"At Night, Alone.",3:19
|
|
||||||
Miksu / Macloud/Makko,Nachts wach (Lila Wolken Bootleg),BRAVO Hits 2023,2:38
|
|
||||||
Milo; Hemlock Ernst,Souvenir,So The Flies Don't Come,3:45
|
|
||||||
Murder He Wrote; Idris Miles,Rave Out,Rave Out,3:16
|
|
||||||
My Sister's Fugazi Shirt,ONE MORE FINAL: I need you,"Man Fears the Darkness, and So He Scrapes Away at the Edges of It With Fire",4:29
|
|
||||||
Namatjira,Sounds Of Sunday Morning (RIGOONI Remix),Moons Of Yesterday (Remix Project 1),7:53
|
|
||||||
Nhato; Kenji Sekiguchi,Tokyo Blue Pipe - Kenji Sekigu,Tokyo Blue Pipe (Kenji Sekiguc,6:26
|
|
||||||
Ninajirachi; daine,It's You,I Love My Computer,2:49
|
|
||||||
Ninajirachi; daine; underscores,"It's You - underscores' ""It’s U"" Remix","It's You (underscores' ""It’s U"" Remix)",3:31
|
|
||||||
Ninajirachi; Effy,CSIRAC - Effy Remix,CSIRAC (Effy Remix),4:33
|
|
||||||
Ninajirachi; horsegiirL,Delete - horsegiirL Remix,Delete (horsegiirL Remix),2:54
|
|
||||||
No Buses,Eyes,Eyes,3:20
|
|
||||||
"NOTION, Chrystal",The Days (NOTION Remix) djsoundtop.com,djsoundtop.com,3:53
|
|
||||||
Odagled,Time Travel (Monikaze Remix),Time Travel,5:23
|
|
||||||
Onaisit,"the bit you were here was fun, maybe that makes it all worth it","the bit you were here was fun, maybe that makes it all worth it",2:28
|
|
||||||
Oncill; lillybug; evangeline; nshvll; Ridoy; clothespin.,Journey Across The Mountain,STORYBOOK: Chapter 2,7:10
|
|
||||||
Open Mike Eagle; Gold Panda,Ziggy Starfish (Anxiety Raps) [feat. Gold Panda],A Special Episode - EP,2:55
|
|
||||||
orbe; haruka nakamura; 田辺玄,ship,orbe Ⅰ,3:50
|
|
||||||
Otira,Pill,Pill,2:09
|
|
||||||
Otira,Take Me,Take Me,3:50
|
|
||||||
Pablo Caballero; Tankhamun,Journey,Journey EP,6:12
|
|
||||||
Pandhora/Sanguinello,Memories (Rasi Z Remix),Imaginary Crossroads,6:58
|
|
||||||
Pansy Alert,Larry Fisherman,Wither EP,5:51
|
|
||||||
Paper Planes,Forever,Forever,2:02
|
|
||||||
Parvenu,Perfect Now,Perfect Now,3:26
|
|
||||||
Pasocom Music Club,hikari,Night Flow,4:07
|
|
||||||
Patrick Holland,Closer,Beaubien Dream,6:57
|
|
||||||
Pete Heller's Big Love; Eat Me,Big Love - Eat Me Edit,Big Love,4:01
|
|
||||||
Peter Palace,Lovahman,Lost And Found,5:47
|
|
||||||
Phil Weeks,The Grind,"Essential Beats, Vol. 5",7:14
|
|
||||||
PinkPantheress; Anz,All My Friends Know - Anz Remix,to hell with it (Remixes),5:11
|
|
||||||
piri; Tommy Villiers,soft spot,soft spot,3:39
|
|
||||||
Pleasurekraft; YellowHeads,Prison Planet,Pleasurekraft presents: Binaries,7:34
|
|
||||||
Polymod; Sasha,Full Circle,Full Circle,6:00
|
|
||||||
Porter Robinson, Everything Goes On (Assertive Hardcore Bootleg), Everything Goes On (Assertive Hardcore Bootleg) - Single,4:28
|
|
||||||
Porter Robinson,Language (Alex H Remix),Language (Alex H Remix) - Single,8:30
|
|
||||||
Porter Robinson,Look at the Sky (AZURA Remix),Look at the Sky (AZURA Remix),4:01
|
|
||||||
Porter Robinson,Musician (Synthion Hardcore Edit),Musician (Synthion Hardcore Edit),3:31
|
|
||||||
Porter Robinson,Something Comforting (Live),Secret Sky 2021 (Live),6:32
|
|
||||||
Porter Robinson,Sea of Voices (Remix) [Live],Secret Sky 2021 (Live),3:52
|
|
||||||
Porter Robinson,Goodbye To a World (Remix) [Live],Secret Sky 2021 (Live),3:01
|
|
||||||
Porter Robinson & Madeon,evil shelter,madeon edit,2:18
|
|
||||||
Porter Robinson & Madeon,Shelter (Live),Secret Sky 2021 (Live),0:05
|
|
||||||
Porter Robinson & Mat Zo,Easy (Pixel Terror DNB Flip),Pixel Terror Treats [Volume Four],2:40
|
|
||||||
Porter Robinson & Totally Enormous Extinct Dinosaurs,Unfold (Live),Secret Sky 2021 (Live),0:06
|
|
||||||
Porter Robinson; Madeon,Intro (Shelter Edit),Shelter Live,2:11
|
|
||||||
Porter Robinson; Madeon,Shelter (Shelter Edit),Shelter Live,3:37
|
|
||||||
Porter Robinson; Madeon,Pay No Mind x Easy (Shelter Edit),Shelter Live,3:57
|
|
||||||
Porter Robinson; Madeon,Sad Machine (Shelter Edit),Shelter Live,4:25
|
|
||||||
Porter Robinson; Madeon,You're On (Shelter Edit),Shelter Live,3:15
|
|
||||||
Porter Robinson; Madeon,OK x Lionhearted (Shelter Edit),Shelter Live,4:19
|
|
||||||
Porter Robinson; Madeon,Flicker (Shelter Edit),Shelter Live,2:57
|
|
||||||
Porter Robinson; Madeon,Finale x Cut The Kid (Shelter Edit),Shelter Live,4:14
|
|
||||||
Porter Robinson; Madeon,Imperium (Shelter Edit),Shelter Live,4:10
|
|
||||||
Porter Robinson; Madeon,Pop Culture (Shelter Edit),Shelter Live,1:20
|
|
||||||
Porter Robinson; Madeon,Divinity x Innocence (Shelter Edit),Shelter Live,6:20
|
|
||||||
Porter Robinson; Madeon,La Lune x Sea Of Voices (Shelter Edit),Shelter Live,2:22
|
|
||||||
Porter Robinson; Madeon,Fresh Static Snow (Shelter Edit),Shelter Live,2:22
|
|
||||||
Porter Robinson; Madeon,Home (Shelter Edit),Shelter Live,3:47
|
|
||||||
Porter Robinson; Madeon,Pixel Empire (Shelter Edit),Shelter Live,2:43
|
|
||||||
Porter Robinson; Madeon,Icarus x Fellow Feeling (Shelter Edit),Shelter Live,3:42
|
|
||||||
Porter Robinson; Madeon,Goodbye to a World (Shelter Edit),Shelter Live,5:01
|
|
||||||
Porter Robinson; Madeon,Encore (Shelter Edit),Shelter Live,5:51
|
|
||||||
projektbau,Easy 2 Love,Easy 2 Love,1:59
|
|
||||||
Qplex/Maurice Kaar,Mountains (Yannek Maunz Remix),Mountains (Yannek Maunz Remix),6:15
|
|
||||||
Raito,Deep Down Inside,Deep Down Inside - EP,5:49
|
|
||||||
RAY VOLPE,LASERBEAM (ANDEREX,LASERBEAM (ANDEREX - Single,3:12
|
|
||||||
Remco Beekwilder,LSD - Original Mix,LSD EP,5:21
|
|
||||||
Roald Velden & EcueD,Moments,Gallery:01 (Compiled By Lessov & Roald Velden),9:15
|
|
||||||
Romina; Franck Dona; Tone Depth,No Education - Tone Depth Remix,No Education (Tone Depth Remix),3:51
|
|
||||||
Rosbeh/Paulii,Oceans,Oceans,4:33
|
|
||||||
Ross from Friends,Talk to Me You'll Understand,You'll Understand,6:57
|
|
||||||
Rudosa,Smash The System,No Man's Land EP,7:01
|
|
||||||
Run The Jewels; Zack De La Rocha,Close Your Eyes (And Count to Fuck) [feat. Zack De La Rocha],Run the Jewels 2,3:54
|
|
||||||
rxflxct,Dreaming,Dreaming,4:03
|
|
||||||
salute; なかむらみなみ,go!,TRUE MAGIC,2:11
|
|
||||||
salute; なかむらみなみ; George Daniel,go! - George Daniel Remix,go! (George Daniel Remixes),3:41
|
|
||||||
Salyu; haruka nakamura,星のクズ α,星のクズ α,2:56
|
|
||||||
Sam Silver; ALRT; Yona; Leap96,Downtime,Downtime,2:36
|
|
||||||
Sans Soucis/Jacana People,Air (feat. Jacana People) (Jacana People Remix),Air (Remixes),3:25
|
|
||||||
Sasson (FR)/idd aziz/FNX OMAR/Taamy,Zani (FNX Omar Remix),Zani,6:25
|
|
||||||
Sebastian Davidson/Cir:cle,River Battles,River Battles,4:40
|
|
||||||
Seigen Tokuzawa; Masaki Hayashi,Iambic 9 Poetry,Drift,3:07
|
|
||||||
Shadow Child; Huxley,Err,Err,5:26
|
|
||||||
Shaolin Cowboy,Last Train to Peckham,2Be with You,6:00
|
|
||||||
Shaolin Cowboy,All Night All Summer,All Night All Summer,4:46
|
|
||||||
SHEE,Close My Eyes (Extended Mix),Close My Eyes,6:06
|
|
||||||
SHEE,Am I Just Living,Miami Ug 2019,6:01
|
|
||||||
Shermanology,Boyz N Da Club,Boyz N Da Club,6:17
|
|
||||||
Shire T,London. Paris. Berlin.,Tomorrow’s People,4:05
|
|
||||||
"skrillex, porter robinson",still here (nuphory x luna lenta lightmode mix),still here (nuphory x luna lenta lightmode mix) - Single,3:56
|
|
||||||
SLIM HUSTLA,2100,Compilation 2,5:48
|
|
||||||
Some Me,Engage,Engage,6:52
|
|
||||||
Staggr [257757361],hit me + ! [1419656392],cloudwire [2082910431],1:24
|
|
||||||
Static,feel,feel,2:54
|
|
||||||
Stereos,Summer Girl,Stereos (Deluxe Version),2:45
|
|
||||||
Steve Reich; Kristjan Järvi; Waltraut Wächter; Andreas Hartmann; MDR Leipzig Radio Symphony Orchestra,Duet for two Solo Violins and String Orchestra (Dedicated to and written for Yehudi Menuhin),Steve Reich - Duet,5:34
|
|
||||||
Sultan + Shepard; Elderbrook,I'll Be Here,"Endless, Dawn",4:52
|
|
||||||
sumthin sumthin,BEEN THRU,Growing Pains,3:26
|
|
||||||
sunflwr,you got your own thing going,you got your own thing going,4:03
|
|
||||||
Sunny Lax; Aneym,Everything's A Lie,Everything's A Lie,3:40
|
|
||||||
Suulo vs. Curl,Navida (Original Mix),"Kittball Konspiracy, Vol. 19",7:16
|
|
||||||
Svetec,Ruka Hore,Ruka Hore,5:16
|
|
||||||
Swimming Paul; Beaux Neptune,Good Girl,Good Girl,3:14
|
|
||||||
t0ni; Broosnica,keepsake Flip,keepsake Flip,3:04
|
|
||||||
T78,Ikarus,Ikarus,5:05
|
|
||||||
T78; Raito,Acid People,Acid People / Make Me Feel,6:59
|
|
||||||
tai hirose,sink,aquatic recitation,3:05
|
|
||||||
tai hirose,rain's footsteps,deepwater / gauze,2:05
|
|
||||||
tai hirose,magnify,magnify,1:02
|
|
||||||
Tame Impala,Borderline - Blood Orange Remix,Borderline,0:12
|
|
||||||
Taylor Swift,Love Story (Jake Silva Remix),Love Story (Jake Silva Remix) - Single,4:34
|
|
||||||
TDJ,Trpaslik Island,SPF INFINI 2,5:11
|
|
||||||
TDJ; fknsyd,Euphoria,Euphoria,4:14
|
|
||||||
TDJ; Peterparker69,ただdance in the dark,ただdance in the dark,4:12
|
|
||||||
Ted Poley; Tony Harnell,Escape From The City ...for City Escape,SONIC ADVENTURE 2 Original Soundtrack (20th Anniversary Edition),2:19
|
|
||||||
Tentendo; Jordan Dennis,Function,Function,3:18
|
|
||||||
Tenth Planet; Vincent de Moor,Ghosts - Vincent De Moor Mix,Ghosts,6:46
|
|
||||||
Terror Jr; AOBeats,Be Some Body,Be Some Body,3:01
|
|
||||||
The Knocks; TEED,Walking On Water (feat. TEED),HISTORY,3:36
|
|
||||||
thomfjord,Intrastellar,Intrastellar EP,5:21
|
|
||||||
thomfjord,Terminal Velocity,Intrastellar EP,5:56
|
|
||||||
thomfjord,Day , Nite,Intrastellar EP
|
|
||||||
Tom Jarmey; Ed Hodge,Deeper Pacific,Amber Glass,6:42
|
|
||||||
Tommie Sunshine; MureKian; Sabrina Signs; Raito,Save the Rave - Raito Remix,Save the Rave,4:30
|
|
||||||
Tommy Holohan; Clouds,South Beach Burnin Bins (Clouds Remix),RVE002,5:32
|
|
||||||
Too $hort,You Came to Party,You Came to Party,3:53
|
|
||||||
Tribz/Calixte,Sweet Disposition (Vijay & Sofia Edit),Sweet Disposition (Vijay & Sofia Edit),3:28
|
|
||||||
Tritonal; Lourdiz; Kyau & Albe,Love U Right - Kyau & Albert R,"Love U Right (Remixes, Pt. 1)",2:49
|
|
||||||
Truncate,Our Bodies,TRUNCEI8HT EP,5:12
|
|
||||||
TWONSKi,DiAMONDS,DiAMONDS,3:08
|
|
||||||
UMEK,Double the Lust - Original Mix,Double the Lust,7:40
|
|
||||||
V. Christie,Talk To Me,Talk To Me,3:53
|
|
||||||
Vanessa Sukowski; ABYSSVM,Lucanus Cervus - Abyssvm Remix,Lucanus Cervus,7:08
|
|
||||||
Varg²™; Bladee; Skrillex,Is there a place in heaven for boys like me?,"Nordic Flora Series, Pt. 6: Outlaw Music",3:41
|
|
||||||
Varra,Catto Observatory - Remix,Catto Observatory (Remix),2:47
|
|
||||||
Veerus,Heavy,FOUR ON JAM 01,6:52
|
|
||||||
Village,C-R-E-W,C-R-E-W,3:25
|
|
||||||
Vincent,Love Me Now (Extended),Love Me Now (Extended) - Single,4:07
|
|
||||||
Vincent de Moor,Fly Away - Extended Vocal Mix,Fly Away,8:21
|
|
||||||
VTSS,Sober Raving,HELLCAT VOL.1,5:38
|
|
||||||
Wassu,Escape,Tales of Romance I,7:12
|
|
||||||
water feature,Pond Bather,Various Birds,1:11
|
|
||||||
wes mills; meli,this time,this time,3:12
|
|
||||||
Willaris. K; jamesjamesjames,Silversun,Silversun,5:37
|
|
||||||
Willo,Past Time,Past Time,5:40
|
|
||||||
Worakls,Detached Motion (Patrice Bdumel Remix),Hortari & Detached Motion Remixes,6:33
|
|
||||||
Worakls/Ben Bvhmer/eivxr,Red Dressed (Ben Bvhmer Remix - Short Version),Red Dressed (Ben Bvhmer Remix),3:46
|
|
||||||
X-COAST,Party Time - Extended,Pianissimo,5:46
|
|
||||||
X-COAST,The Ultimate - Extended,Pianissimo,6:09
|
|
||||||
Yeek,Long Time No See,Long Time No See,1:51
|
|
||||||
yuragi,soon,nightlife,2:46
|
|
||||||
yuragi,night is young,nightlife,5:44
|
|
||||||
Zigan Aldi,Heppnar (Iorie Remix),Heppnar (Iorie Remix),7:22
|
|
||||||
Zmi,Shigatsu ame,Fu-ne,2:50
|
|
||||||
zodivk,yours (+),yours (+),2:55
|
|
||||||
adri; dossyx; Feathervane; Stardreams; wintercolor,Atonement,STORYBOOK: Chapter 2,6:12
|
|
||||||
⣎⡇ꉺლ༽இ•̛)ྀ◞ ༎ຶ ༽ৣৢ؞ৢ؞ؖ ꉺლ,ƪ. ◖ƪ❍⊁◞.,◗щ (*ㅇ△₊⁎❝᷀ົཽ*ೃ:(꒡͡ ❝᷀ົཽ ꉺ¨.·*:・✧⃛(ཽ๑,)✧⃛*
|
|
||||||
|
@@ -1,46 +1,30 @@
|
|||||||
# /opt/sldl/configs/artist-canonical.list
|
# artist-canonical.list
|
||||||
#
|
#
|
||||||
# Canonical artist-name casing. One name per line; '#' starts a comment.
|
# Canonical artist-name casing. One name per line; '#' starts a comment.
|
||||||
# Names here are written exactly as Spotify (or the artist) stylizes them.
|
# Write each name exactly as you want it stored (usually how Spotify or the
|
||||||
|
# artist stylizes it).
|
||||||
#
|
#
|
||||||
# normalize-artist-casing.py looks up every distinct $albumartist value in the
|
# The normalize-artist-casing job looks up every distinct album-artist value in
|
||||||
# beets library; for any value that matches a name here case-insensitively but
|
# your library. For any value that matches a name here case-insensitively but
|
||||||
# differs in casing, it runs `beet modify` to rewrite the tag to the canonical
|
# differs in casing, it rewrites the tag to the form below, and beets then moves
|
||||||
# form. beets then moves the album folder to the canonical path.
|
# the album folder to the canonical path.
|
||||||
#
|
#
|
||||||
# Purpose: prevents Linux → Windows Syncthing collisions like `Mall Grab/` vs
|
# Why this matters: it prevents Linux-to-Windows Syncthing collisions like
|
||||||
# `MALL GRAB/` (same path on Windows, different on Linux).
|
# `Mall Grab/` versus `MALL GRAB/` (the same folder on Windows, two folders on
|
||||||
|
# Linux), and keeps one artist from being split across differently-cased folders.
|
||||||
#
|
#
|
||||||
# To add an artist:
|
# To add an artist:
|
||||||
# 1. Look them up on Spotify, copy their displayed name exactly.
|
# 1. Look them up and copy their displayed name exactly.
|
||||||
# 2. Append a new line below.
|
# 2. Add a line below.
|
||||||
# 3. Run /opt/sldl/lib/normalize-artist-casing.py to preview, then --apply.
|
# 3. Preview from Settings then Artist Casing, or run the job from Settings
|
||||||
|
# then Jobs.
|
||||||
#
|
#
|
||||||
# Characters illegal on Windows (`<>:"\?*|`) can appear here — beets's `replace:`
|
# Characters that are illegal in Windows filenames (< > : " \ ? * |) are allowed
|
||||||
# rule substitutes them with `_` when generating the on-disk path, so the tag
|
# here. beets substitutes them with `_` when building the on-disk path, so the
|
||||||
# stays Spotify-correct while the folder stays Windows-safe (e.g. `il:lo` tag
|
# tag stays correct while the folder stays Windows-safe.
|
||||||
# maps to `il_lo/` folder).
|
#
|
||||||
|
# This file ships empty. Add your own names below, for example:
|
||||||
Bicep
|
#
|
||||||
bôa
|
# Mall Grab
|
||||||
Charli xcx
|
# Charli xcx
|
||||||
Charlotte de Witte
|
# deadmau5
|
||||||
Crush3d
|
|
||||||
deadmau5
|
|
||||||
DJ HEARTSTRING
|
|
||||||
DjRUM
|
|
||||||
Doechii
|
|
||||||
Eprom
|
|
||||||
FM Forest
|
|
||||||
il:lo
|
|
||||||
KETTAMA
|
|
||||||
Mall Grab
|
|
||||||
nanobii
|
|
||||||
Pasocom Music Club
|
|
||||||
Porter Robinson
|
|
||||||
Ross from Friends
|
|
||||||
Shades
|
|
||||||
Svetec
|
|
||||||
UMEK
|
|
||||||
yuragi
|
|
||||||
YOTTO
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# cross-album-keep.list
|
||||||
|
#
|
||||||
|
# Artists to protect from the cross-album duplicate pass (dedup Pass 4).
|
||||||
|
#
|
||||||
|
# Pass 4 looks for the same track title appearing under one artist across two
|
||||||
|
# or more different albums, which usually means a genuine duplicate. But some
|
||||||
|
# artists legitimately have the same title on several albums: compilation and
|
||||||
|
# "Various Artists" entries, prolific remixers, and artists with many live
|
||||||
|
# recordings. List those artists here and Pass 4 will leave them alone.
|
||||||
|
#
|
||||||
|
# One artist per line; '#' starts a comment. Casing and punctuation do not
|
||||||
|
# matter (names are compared with everything but letters and numbers removed),
|
||||||
|
# so "Porter Robinson", "porterrobinson", and "PORTER ROBINSON" are the same.
|
||||||
|
#
|
||||||
|
# This file ships empty, which means Pass 4 considers every artist. Add names
|
||||||
|
# only if you find Pass 4 repeatedly flagging an artist's legitimate versions.
|
||||||
|
# Nothing is ever deleted without your review in the Dedup page regardless.
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
#
|
||||||
|
# Various Artists
|
||||||
|
# Porter Robinson
|
||||||
@@ -4,32 +4,17 @@
|
|||||||
# are passed as a single beet query argument. Lines containing | are split on |
|
# are passed as a single beet query argument. Lines containing | are split on |
|
||||||
# and each part is passed as a separate beet query term (ANDed together).
|
# and each part is passed as a separate beet query term (ANDed together).
|
||||||
#
|
#
|
||||||
# Use | to combine albumartist and album constraints, e.g.:
|
# Use | to combine an album-artist and an album constraint, for example:
|
||||||
# albumartist::Porter Robinson | album:Secret Sky 2021
|
# albumartist::Some Artist | album:Some Live Set 2024
|
||||||
#
|
#
|
||||||
# Single-field lines with spaces work fine as one arg — beet treats the whole
|
# Single-field lines with spaces work fine as one argument, because beet treats
|
||||||
# string after the colon as the value. Do NOT word-split commas-in-names.
|
# the whole string after the colon as the value. Do not split names on commas.
|
||||||
#
|
#
|
||||||
# To add an album, find the exact name first:
|
# To find the exact album name in your own library first:
|
||||||
# docker exec soulbeet beet ls -f '$albumartist|$album' 'artist:Name' | sort -u
|
# beet ls -f '$albumartist|$album' 'artist:Name' | sort -u
|
||||||
# Then add a line and run: sudo /opt/sldl/lib/gen-djmix-playlist.sh
|
# Then add a line and run the DJ-mix playlist job.
|
||||||
|
#
|
||||||
# ---- Fred again.. — Live DJ sets ----
|
# This file ships empty. Add your own albums below, for example:
|
||||||
album:Apple Music Live: Fred again..
|
#
|
||||||
album:Live from Lyon, France, Oct 24, 2025
|
# album:Some Live Set 2024
|
||||||
album:Alexandra Palace, London, Feb 27, 2026
|
# albumartist::Some Artist | album:Secret Show 2023
|
||||||
album:Live from Toronto, Canada, Nov 14, 2025
|
|
||||||
|
|
||||||
# ---- Porter Robinson — Live performances / DJ sets ----
|
|
||||||
albumartist::Porter Robinson | album:Secret Sky 2021
|
|
||||||
albumartist::Porter Robinson | album:Worlds Live at Second Sky 2019
|
|
||||||
albumartist::Porter Robinson | album:Shelter Live
|
|
||||||
|
|
||||||
# ---- Virtual Self b2b G Jones ----
|
|
||||||
albumartist::Virtual Self | album:Second Sky 2022
|
|
||||||
|
|
||||||
# ---- Air2Earth ----
|
|
||||||
albumartist::^Air2Earth$ | album:Splash House
|
|
||||||
|
|
||||||
# ---- G Jones — add more DJ mix albums below ----
|
|
||||||
# Find album names with: docker exec soulbeet beet ls -f '$albumartist|$album' 'artist:"G Jones"' | sort -u
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "techno",
|
||||||
|
"spotify_url": "https://open.spotify.com/playlist/REPLACE_WITH_YOUR_PLAYLIST_ID",
|
||||||
|
"cron_expr": "0 1 * * *",
|
||||||
|
"no_m3u": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "liked",
|
||||||
|
"spotify_url": "https://open.spotify.com/playlist/REPLACE_WITH_YOUR_PLAYLIST_ID",
|
||||||
|
"cron_expr": "30 23 * * *",
|
||||||
|
"no_m3u": true
|
||||||
|
}
|
||||||
|
]
|
||||||
+36
-13
@@ -32,18 +32,41 @@ fi
|
|||||||
|
|
||||||
mkdir -p "$CONFIG_OUT_DIR"
|
mkdir -p "$CONFIG_OUT_DIR"
|
||||||
|
|
||||||
# One "name<TAB>spotify_url" line per playlist, active or not (see header).
|
# Render each playlist's .conf entirely in Python. The playlist name and URL
|
||||||
python3 -c "
|
# are substituted as LITERAL text (single-pass regex replace, never re-scanned),
|
||||||
import json, sys
|
# not fed to `sed` — an earlier sed-based version let a crafted playlist name
|
||||||
with open(sys.argv[1]) as f:
|
# execute shell via GNU sed's `e` flag. Names are also re-validated here as
|
||||||
|
# defense in depth even though the app validates them before writing the JSON.
|
||||||
|
python3 - "$PLAYLISTS_JSON" "$TEMPLATE" "$CONFIG_OUT_DIR" "$SLDL_DROPBOX_ROOT" <<'PY'
|
||||||
|
import json, os, re, sys
|
||||||
|
|
||||||
|
playlists_json, template_path, out_dir, dropbox_root = sys.argv[1:5]
|
||||||
|
|
||||||
|
with open(playlists_json) as f:
|
||||||
playlists = json.load(f)
|
playlists = json.load(f)
|
||||||
|
with open(template_path) as f:
|
||||||
|
template = f.read()
|
||||||
|
|
||||||
|
SAFE_NAME = re.compile(r"^[A-Za-z0-9 _-]{1,64}$")
|
||||||
|
|
||||||
for p in playlists:
|
for p in playlists:
|
||||||
print(p['name'] + '\t' + p['spotify_url'])
|
name = p.get("name", "")
|
||||||
" "$PLAYLISTS_JSON" | while IFS=$'\t' read -r name url; do
|
url = p.get("spotify_url", "")
|
||||||
[[ -z "$name" ]] && continue
|
if not SAFE_NAME.match(name):
|
||||||
sed -e "s|PLAYLIST_NAME|${name}|g" \
|
print(f"WARNING: skipping playlist with unsafe name: {name!r}", file=sys.stderr)
|
||||||
-e "s|SPOTIFY_URL|${url}|g" \
|
continue
|
||||||
-e "s|SLDL_DROPBOX_ROOT|${SLDL_DROPBOX_ROOT}|g" \
|
mapping = {
|
||||||
"$TEMPLATE" > "$CONFIG_OUT_DIR/${name}.conf"
|
"PLAYLIST_NAME": name,
|
||||||
echo "Generated $CONFIG_OUT_DIR/${name}.conf"
|
"SPOTIFY_URL": url,
|
||||||
done
|
"SLDL_DROPBOX_ROOT": dropbox_root,
|
||||||
|
}
|
||||||
|
pattern = re.compile("|".join(re.escape(k) for k in mapping))
|
||||||
|
rendered = pattern.sub(lambda m: mapping[m.group(0)], template)
|
||||||
|
out_path = os.path.join(out_dir, name + ".conf")
|
||||||
|
with open(out_path, "w") as f:
|
||||||
|
f.write(rendered)
|
||||||
|
# Holds the Soulseek password and Spotify secret once credentials are
|
||||||
|
# patched in, so keep it owner-only from the moment it is created.
|
||||||
|
os.chmod(out_path, 0o600)
|
||||||
|
print(f"Generated {out_path}")
|
||||||
|
PY
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ def main():
|
|||||||
ap.add_argument("--force", action="store_true",
|
ap.add_argument("--force", action="store_true",
|
||||||
help="overwrite an existing buy-link tag")
|
help="overwrite an existing buy-link tag")
|
||||||
ap.add_argument("--azuracast-key", default=None)
|
ap.add_argument("--azuracast-key", default=None)
|
||||||
ap.add_argument("--azuracast-base", default="https://admin.ephemeral.club")
|
ap.add_argument("--azuracast-base", default=os.environ.get("AZURACAST_BASE", ""))
|
||||||
ap.add_argument("--station", type=int, default=1)
|
ap.add_argument("--station", type=int, default=1)
|
||||||
ap.add_argument("--username", default=None,
|
ap.add_argument("--username", default=None,
|
||||||
help="Bandcamp username; default read from config.env if present")
|
help="Bandcamp username; default read from config.env if present")
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ beet move 2>&1 | tail -5 >> "$LOG"
|
|||||||
NAVIDROME_ENV="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/navidrome/admin.env"
|
NAVIDROME_ENV="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/navidrome/admin.env"
|
||||||
[ -f "$NAVIDROME_ENV" ] && source "$NAVIDROME_ENV"
|
[ -f "$NAVIDROME_ENV" ] && source "$NAVIDROME_ENV"
|
||||||
ND_BASE="${ND_BASE:-http://navidrome:4533}"
|
ND_BASE="${ND_BASE:-http://navidrome:4533}"
|
||||||
ND_USER="${ND_USER:-andrew}"
|
ND_USER="${ND_USER:-}"
|
||||||
ND_PASS="${ND_PASS:-}"
|
ND_PASS="${ND_PASS:-}"
|
||||||
|
|
||||||
echo "[$(date -Iseconds)] Triggering Navidrome full scan" | tee -a "$LOG"
|
echo "[$(date -Iseconds)] Triggering Navidrome full scan" | tee -a "$LOG"
|
||||||
|
|||||||
@@ -407,8 +407,23 @@ log "[$(date -Iseconds)] === Pass 4: cross-album dedup (artist+title-base) ==="
|
|||||||
beet ls -f "\$id${SEP}\$albumartist${SEP}\$album${SEP}\$title${SEP}\$path" \
|
beet ls -f "\$id${SEP}\$albumartist${SEP}\$album${SEP}\$title${SEP}\$path" \
|
||||||
2>/dev/null > /tmp/all-tracks.txt
|
2>/dev/null > /tmp/all-tracks.txt
|
||||||
|
|
||||||
# Pass artist + album allowlists into awk via -v
|
# Pass artist + album allowlists into awk via -v.
|
||||||
CROSS_ALBUM_KEEP_RE='^(porterrobinson|madeon|variousartists)$'
|
# The artist keep-list (artists whose same-title tracks across albums are NOT
|
||||||
|
# treated as duplicates) is user-editable config, not baked in here. Build the
|
||||||
|
# anchored regex from cross-album-keep.list: normalize each name the same way
|
||||||
|
# the awk norm() does (lowercase, letters+digits only) and OR them together.
|
||||||
|
# An empty/absent list means "protect nobody" -> use a pattern that can never
|
||||||
|
# match a normalized name (which only ever contains [a-z0-9], never '_').
|
||||||
|
CROSS_ALBUM_KEEP_FILE="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/cross-album-keep.list"
|
||||||
|
if [[ -f "$CROSS_ALBUM_KEEP_FILE" ]]; then
|
||||||
|
_keep_names=$(grep -vE '^[[:space:]]*(#|$)' "$CROSS_ALBUM_KEEP_FILE" \
|
||||||
|
| tr 'A-Z' 'a-z' | sed -E 's/[^a-z0-9]//g' | grep -v '^$' | paste -sd'|' -)
|
||||||
|
fi
|
||||||
|
if [[ -n "${_keep_names:-}" ]]; then
|
||||||
|
CROSS_ALBUM_KEEP_RE="^(${_keep_names})\$"
|
||||||
|
else
|
||||||
|
CROSS_ALBUM_KEEP_RE='_never_'
|
||||||
|
fi
|
||||||
# Album-name patterns that indicate "intentional alt version" — these rows are
|
# Album-name patterns that indicate "intentional alt version" — these rows are
|
||||||
# filtered out before grouping, so any group needing 2+ matches will only form
|
# filtered out before grouping, so any group needing 2+ matches will only form
|
||||||
# from items whose albums DON'T look like remix/live/single-version releases.
|
# from items whose albums DON'T look like remix/live/single-version releases.
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ def main():
|
|||||||
"re-matching — converts by album id. Skips links that "
|
"re-matching — converts by album id. Skips links that "
|
||||||
"aren't purchasable (leaves them intact).")
|
"aren't purchasable (leaves them intact).")
|
||||||
ap.add_argument("--azuracast-key", default=None)
|
ap.add_argument("--azuracast-key", default=None)
|
||||||
ap.add_argument("--azuracast-base", default="https://admin.ephemeral.club")
|
ap.add_argument("--azuracast-base", default=os.environ.get("AZURACAST_BASE", ""))
|
||||||
ap.add_argument("--station", type=int, default=1)
|
ap.add_argument("--station", type=int, default=1)
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ def _load_navidrome_env():
|
|||||||
|
|
||||||
_nd_env = _load_navidrome_env()
|
_nd_env = _load_navidrome_env()
|
||||||
ND_BASE = _nd_env.get("ND_BASE", "http://navidrome:4533")
|
ND_BASE = _nd_env.get("ND_BASE", "http://navidrome:4533")
|
||||||
ND_USER = _nd_env.get("ND_USER", "andrew")
|
ND_USER = _nd_env.get("ND_USER", "")
|
||||||
ND_PASS = _nd_env.get("ND_PASS", "")
|
ND_PASS = _nd_env.get("ND_PASS", "")
|
||||||
ND_API_VERSION = "1.16.0"
|
ND_API_VERSION = "1.16.0"
|
||||||
ND_CLIENT = "dj-export"
|
ND_CLIENT = "dj-export"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ mkdir -p "$(dirname "$LOG")"
|
|||||||
NAVIDROME_ENV="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/navidrome/admin.env"
|
NAVIDROME_ENV="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/navidrome/admin.env"
|
||||||
[ -f "$NAVIDROME_ENV" ] && source "$NAVIDROME_ENV"
|
[ -f "$NAVIDROME_ENV" ] && source "$NAVIDROME_ENV"
|
||||||
ND_BASE="${ND_BASE:-http://navidrome:4533}"
|
ND_BASE="${ND_BASE:-http://navidrome:4533}"
|
||||||
ND_USER="${ND_USER:-andrew}"
|
ND_USER="${ND_USER:-}"
|
||||||
ND_PASS="${ND_PASS:-}"
|
ND_PASS="${ND_PASS:-}"
|
||||||
log() { echo "[$(date -Iseconds)] $*" | tee -a "$LOG"; }
|
log() { echo "[$(date -Iseconds)] $*" | tee -a "$LOG"; }
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ def _load_navidrome_env():
|
|||||||
|
|
||||||
_nd_env = _load_navidrome_env()
|
_nd_env = _load_navidrome_env()
|
||||||
NAVIDROME_URL = f"{_nd_env.get('ND_BASE', 'http://navidrome:4533')}/rest/startScan.view"
|
NAVIDROME_URL = f"{_nd_env.get('ND_BASE', 'http://navidrome:4533')}/rest/startScan.view"
|
||||||
NAVIDROME_USER = _nd_env.get("ND_USER", "andrew")
|
NAVIDROME_USER = _nd_env.get("ND_USER", "")
|
||||||
NAVIDROME_PASS = _nd_env.get("ND_PASS", "")
|
NAVIDROME_PASS = _nd_env.get("ND_PASS", "")
|
||||||
|
|
||||||
VORBIS_LIKE = (FLAC, OggOpus, OggVorbis)
|
VORBIS_LIKE = (FLAC, OggOpus, OggVorbis)
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ _nd_env = _load_navidrome_env()
|
|||||||
|
|
||||||
# Subsonic
|
# Subsonic
|
||||||
ND_BASE = _nd_env.get("ND_BASE", "http://navidrome:4533")
|
ND_BASE = _nd_env.get("ND_BASE", "http://navidrome:4533")
|
||||||
ND_USER = _nd_env.get("ND_USER", "andrew")
|
ND_USER = _nd_env.get("ND_USER", "")
|
||||||
ND_PASS = _nd_env.get("ND_PASS", "")
|
ND_PASS = _nd_env.get("ND_PASS", "")
|
||||||
ND_API_VERSION = "1.16.0"
|
ND_API_VERSION = "1.16.0"
|
||||||
ND_CLIENT = "dj-import"
|
ND_CLIENT = "dj-import"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ LIB="$MUSIC_DATA_DIR/Library"
|
|||||||
CANARY="$LIB/.navidrome-canary"
|
CANARY="$LIB/.navidrome-canary"
|
||||||
MIN_ARTIST_DIRS=500 # library has ~1500; 500 is "clearly not wiped"
|
MIN_ARTIST_DIRS=500 # library has ~1500; 500 is "clearly not wiped"
|
||||||
ND="${ND_BASE:-http://navidrome:4533}"
|
ND="${ND_BASE:-http://navidrome:4533}"
|
||||||
ND_USER="${ND_USER:-andrew}"
|
ND_USER="${ND_USER:-}"
|
||||||
ND_PASS="${ND_PASS:-}"
|
ND_PASS="${ND_PASS:-}"
|
||||||
|
|
||||||
log() { echo "[$(date -Iseconds)] navidrome-scan: $*"; }
|
log() { echo "[$(date -Iseconds)] navidrome-scan: $*"; }
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ NOW_TS=$(date +%s)
|
|||||||
NAVIDROME_ENV="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/navidrome/admin.env"
|
NAVIDROME_ENV="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/navidrome/admin.env"
|
||||||
[ -f "$NAVIDROME_ENV" ] && source "$NAVIDROME_ENV"
|
[ -f "$NAVIDROME_ENV" ] && source "$NAVIDROME_ENV"
|
||||||
ND_BASE="${ND_BASE:-http://navidrome:4533}"
|
ND_BASE="${ND_BASE:-http://navidrome:4533}"
|
||||||
ND_USER="${ND_USER:-andrew}"
|
ND_USER="${ND_USER:-}"
|
||||||
ND_PASS="${ND_PASS:-}"
|
ND_PASS="${ND_PASS:-}"
|
||||||
|
|
||||||
# Tally counters for the header banner. Body collected first, then prepended.
|
# Tally counters for the header banner. Body collected first, then prepended.
|
||||||
@@ -366,7 +366,7 @@ except Exception as e:
|
|||||||
|
|
||||||
# ---- Build header banner and prepend ----
|
# ---- Build header banner and prepend ----
|
||||||
HOSTNAME_SHORT=$(hostname -s)
|
HOSTNAME_SHORT=$(hostname -s)
|
||||||
DATE_HUMAN=$(TZ=America/Edmonton date '+%a %Y-%m-%d %H:%M %Z')
|
DATE_HUMAN=$(TZ="${TZ:-UTC}" date '+%a %Y-%m-%d %H:%M %Z')
|
||||||
BANNER_TITLE="🎵 Music pipeline · $HOSTNAME_SHORT · $DATE_HUMAN"
|
BANNER_TITLE="🎵 Music pipeline · $HOSTNAME_SHORT · $DATE_HUMAN"
|
||||||
BANNER_TALLY=" $OK OK · $WARN warn · $SKIP skip"
|
BANNER_TALLY=" $OK OK · $WARN warn · $SKIP skip"
|
||||||
sed -i "1c\\
|
sed -i "1c\\
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Default is dry-run. Pass --apply to actually write tags + assign.
|
|||||||
|
|
||||||
Env / args:
|
Env / args:
|
||||||
--azuracast-key KEY AzuraCast API key (required)
|
--azuracast-key KEY AzuraCast API key (required)
|
||||||
--azuracast-base URL default https://admin.ephemeral.club
|
--azuracast-base URL default from AZURACAST_BASE env (empty if unset)
|
||||||
--station N default 1
|
--station N default 1
|
||||||
--apply actually act (default is dry-run)
|
--apply actually act (default is dry-run)
|
||||||
--only PLAYLIST limit recovery to one playlist (testing)
|
--only PLAYLIST limit recovery to one playlist (testing)
|
||||||
@@ -157,7 +157,7 @@ def beet_update(container_paths: list[str], log) -> None:
|
|||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("--azuracast-key", required=True)
|
ap.add_argument("--azuracast-key", required=True)
|
||||||
ap.add_argument("--azuracast-base", default="https://admin.ephemeral.club")
|
ap.add_argument("--azuracast-base", default=os.environ.get("AZURACAST_BASE", ""))
|
||||||
ap.add_argument("--station", type=int, default=1)
|
ap.add_argument("--station", type=int, default=1)
|
||||||
ap.add_argument("--apply", action="store_true")
|
ap.add_argument("--apply", action="store_true")
|
||||||
ap.add_argument("--only", default=None, help="limit recovery to one playlist name")
|
ap.add_argument("--only", default=None, help="limit recovery to one playlist name")
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ _IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp")
|
|||||||
|
|
||||||
# --- Buy-link tagging -------------------------------------------------------
|
# --- Buy-link tagging -------------------------------------------------------
|
||||||
# We stamp the release's Bandcamp page into the file so AzuraCast can show a
|
# We stamp the release's Bandcamp page into the file so AzuraCast can show a
|
||||||
# "Buy" button on ephemeral.club. AzuraCast only auto-assigns custom fields
|
# "Buy" button on your station. AzuraCast only auto-assigns custom fields
|
||||||
# from its *known* tag enum, so a bespoke "BUY_URL" tag would be ignored
|
# from its *known* tag enum, so a bespoke "BUY_URL" tag would be ignored
|
||||||
# (it lands in extraTags). Instead we use the "Commercial Information" tag —
|
# (it lands in extraTags). Instead we use the "Commercial Information" tag —
|
||||||
# ID3 frame WCOM, getID3 key `commercial_information`, semantically "a webpage
|
# ID3 frame WCOM, getID3 key `commercial_information`, semantically "a webpage
|
||||||
|
|||||||
Reference in New Issue
Block a user