Data & Security Model¶
This page describes where every piece of credential, cache, and history data lives, how it is protected (or not), and what self-hosted operators should consider before exposing the app outside a trusted network.
Trust model in one sentence
This app is designed to run on a single-user, trusted host or LAN. The web dashboard has no built-in authentication, and credentials are stored plaintext on disk. Put it behind a reverse proxy with auth (or keep it on localhost) before exposing it to the internet.
At a glance¶
| Asset | Location | Encryption at rest | In git? |
|---|---|---|---|
| YouTube Music auth headers | browser.json |
None | No |
| Last.fm API key + username | .env |
None | No |
| Flask session secret | .env (FLASK_SECRET_KEY) |
None | No |
| Webhook URL | .env (WEBHOOK_URL) |
None | No |
| Search / playlist / tag caches | runtime/*.json |
None | No |
| History database (opt-in) | runtime/history.db (SQLite, WAL) |
None | No |
| Local Last.fm history (opt-in) | runtime/lastfm_history.db (SQLite, WAL) |
None | No |
| User overrides & custom playlists | config/*.json |
None | No (.example only) |
| Encrypted backup bundles | User-chosen .bin file |
AES-256-GCM + Argon2id | No |
1. YouTube Music authentication¶
File: browser.json (configurable via YTM_AUTH_PATH,
see src/config.py)
ytmusicapi browser auth is used, which means the file contains a JSON object of
raw browser request headers: Authorization, SAPISIDHASH, the __Secure-*
cookies, Cookie, User-Agent, x-goog-* identifiers, etc.
- Loaded by:
src/ytm/client.pyvia the upstreamYTMusic()constructor; orchestrated insrc/workflows/_common.py. - Submission UI: the dashboard's "Setup" flow accepts pasted browser headers
and writes them to disk (
web/routes/auth.py). The route is not authenticated. - Encryption: none. Anyone with read access to the file can act as your Google account against YouTube Music until the cookies expire.
- Rotation: manual. Cookies expire periodically; re-paste headers when the dashboard reports auth errors.
- Permissions (Docker): the entrypoint normalises ownership to the
lastfmuser and664mode (devops/docker-entrypoint.sh). On the host you should keep the file600if running outside the container. - Git: excluded via
.gitignore.
2. Last.fm credentials¶
Stored as environment variables in .env:
LASTFM_USER- username (not sensitive).LASTFM_API_KEY- API key. The dataclass field is markedrepr=Falseinsrc/config.pyso it is excluded from log output and tracebacks.
There is no Last.fm session key - the app only reads public scrobbles, so
write-scope auth is never requested. The setup wizard can write these values via
the unauthenticated /api/setup/lastfm endpoint
(web/routes/api.py).
3. Runtime directory¶
All runtime state lives under runtime/ (overridable via RUNTIME_DIR; the
legacy CACHE_DIR env var still works as an alias). Installs that predate the
rename keep a cache/ directory next to the project; on startup its contents are
moved to runtime/ automatically. Every cache file is plain JSON written through
JSONCache in
src/cache/__init__.py, which uses:
- atomic writes (temp file +
os.replace), and fcntl.flock()for cross-process safety.
| File | Holds | Sensitivity |
|---|---|---|
.search_cache.json |
artist\|title → video_id, yt_title, timestamp (30-day TTL; 7 days for negative hits) |
Medium |
.playlist_cache.json |
Playlist IDs and last-synced video-ID templates | Medium (playlist IDs) |
.tag_cache.json |
Last.fm tag lookups (90-day TTL) | Low |
.theme_overrides.json |
Dashboard theme tweaks | None |
.last_run_log.json |
Last sync's resolution map (powers the UI table) | Medium |
.tag_sync_counter.json |
Tag-sync cadence counter | None |
.update_check.json |
GitHub release version cache | None |
.notifications.json |
Web notification queue | None |
history.db (+ -shm, -wal) |
Opt-in audit DB (see below) | High |
lastfm_history.db (+ -shm, -wal) |
Opt-in full scrobble library (see below) | High |
None of these are encrypted. None contain Last.fm or Google credentials.
4. History database (optional)¶
Disabled by default. Enable with HISTORY_DB_ENABLED=true
(src/config.py). Path: HISTORY_DB_FILE (default
runtime/history.db).
SQLite with WAL mode, foreign keys, thread-local connections - implemented in
src/history/db.py. Schema (v3):
tracks- every resolved/missedartist|title, the chosenvideo_id, resolutionsource(search / cache / override / not_found / *_backfill),first_seen,last_seen, hit/miss counters, best match score.syncs- one row per sync run: timestamps, duration, type (main/tags), trigger (manual/web/scheduled), status, track counts, API and cache metrics, truncated error message.actions- audit log of user/system events: blacklist edits, override edits, cache clears, sync errors, substitutions, custom-playlist and tag events, backfills. Source isweborcli.
This DB is the most PII-rich artefact in the project: it's a full listening and operator-action log. Treat it like a personal journal.
Retention and vacuum. Two opt-in knobs keep the file from growing without bound, both enforced after every successful main sync (and also exposed via the Vacuum & Prune button in Settings → History Database):
HISTORY_RETENTION_DAYS(default0= disabled) - deletes anysyncsandactionsrows older than the cutoff, then runsVACUUMto release the freed pages back to the filesystem.tracksrows are deliberately preserved because they are cumulative lookup state, not audit history.HISTORY_MAX_SIZE_MB(default0= unlimited) - if the file is larger than the limit, deletes the oldest 100actionsand 50syncsper pass until the file fits, thenVACUUMs.tracksare also preserved here.
There is no automatic encryption of the file on disk - rely on filesystem-level controls (LUKS, APFS FileVault, host permissions) if confidentiality matters.
Export / import. The full database can be dumped as JSON via
GET /api/history/export (or Settings → Data Management → History
Database → Export), and re-imported via POST /api/history/import
(multipart upload with a mode=merge|replace field, exposed in the UI as the
Import button + a confirmation modal). Both endpoints require
HISTORY_DB_ENABLED=true on the target instance. Merge is idempotent
(syncs deduped on (started_at, sync_type), actions on the full content
tuple, tracks upserted on (artist, title)), so re-importing the same dump
is safe. Replace wipes the existing DB first. The exported JSON file is
plaintext - treat it with the same care as the raw DB. To carry it
encrypted, include the History database option inside a
Teleporter bundle instead, which wraps the same JSON dump in
AES-256-GCM.
5. Local Last.fm history database (optional)¶
Disabled by default. Enable with USE_LOCAL_LASTFM_DB=true
(src/config.py). Path:
LASTFM_LOCAL_DB_FILE (default runtime/lastfm_history.db). See the
Local Last.fm History page for the feature itself.
This is a separate database from the History database in section 4. It crawls
your entire Last.fm scrobble history into one aggregated row per unique
(artist, track) - lifetime play count plus first/last-played timestamps -
implemented in src/lastfm/local_db.py. It stores no
per-play rows, so even large libraries stay small.
- Sensitivity: High. Like the History database, it is a full record of your listening habits - treat it as personal data. It contains no credentials.
- Encryption: none on disk. Rely on filesystem-level controls if confidentiality matters.
- Export / import. Dump as plaintext JSON via
GET /api/lastfm-db/export(or Settings → Data Management), re-import viaPOST /api/lastfm-db/import(multipart upload,mode=merge|replace). Merge is idempotent - play counts take the maximum rather than summing, so re-importing the same dump changes nothing. Both endpoints requireUSE_LOCAL_LASTFM_DB=trueon the target instance. The exported JSON is plaintext; carry it encrypted inside a Teleporter bundle (tick Local Last.fm database) if you need it protected at rest. - Git: excluded via the
/runtime/rule like every other runtime file.
6. Encrypted backup / restore (Teleporter)¶
The only place the app encrypts anything at rest. See
Teleporter docs and
web/services/teleporter.py.
- Cipher: AES-256-GCM (AEAD).
- KDF: Argon2id - 128 MiB memory, 3 iterations, 4 threads.
- Nonce: 12 random bytes per export; salt: 16 random bytes.
- Format:
TPRTmagic header + version + metadata + ciphertext. - Password policy: minimum 8 characters, enforced server-side.
- Dependencies:
cryptography>=41,argon2-cffi>=23.1.
A bundle can include .env, browser.json, all config/*.json overrides, and
(optionally) any chosen cache files. The bundle contains your Last.fm API
key and YouTube cookies - store the .bin file and its password as you would
any password vault export.
7. Web dashboard security¶
- No login. There is no user table, no password, no API token. Every route
under
/api/*,/auth/*,/sync/*, and/actions/*is reachable by anyone who can hit the port. - No CSRF protection. Flask-WTF is not installed; state-changing endpoints accept any same-origin (or cross-origin, depending on browser) POST.
- Flask session secret (
FLASK_SECRET_KEY): generated once viasecrets.token_hex(32)and appended to.envunder anAUTO-GENERATEDsection byweb/app.py. It persists across restarts; deleting it invalidates any open sessions. - Cookies: Flask defaults (HTTPOnly). Mark them
Secureby terminating TLS at a reverse proxy.
Recommended deployment: bind to 127.0.0.1 (or a private Docker network)
and front it with nginx/Caddy/Traefik doing TLS + Basic Auth, OAuth2 Proxy,
Authelia, Tailscale ACLs, or similar.
8. Docker¶
devops/docker-compose.yml mounts host paths
read-write so the container can persist state back to the host:
volumes:
- ../runtime:/app/runtime
- ../config:/app/config
- ../browser.json:/app/browser.json
- ../.env:/app/.env
- ../.env.example:/app/.env.example:ro
- /etc/localtime:/etc/localtime:ro
- The container runs as a non-root
lastfmuser; the entrypoint matches its UID/GID to the owner of/app/configso host-side files keep sensible permissions (devops/docker-entrypoint.sh). - No Docker secrets are used. Credentials are bind-mounted from the host
.env/browser.json. If you prefer Docker/Swarm/K8s secrets, mount them at the same in-container paths. - Avoid putting
LASTFM_API_KEYinenvironment:in compose - it ends up indocker inspectoutput. Stick with the.envmount.
9. Webhooks¶
Configured via WEBHOOK_URL, WEBHOOK_EVENTS, and WEBHOOK_ALLOW_PRIVATE
(src/webhook.py).
- SSRF guard. Before sending, the URL must use
http/httpsand - unlessWEBHOOK_ALLOW_PRIVATE=true- must resolve to a public IP. Private, loopback, link-local, reserved, multicast, and unspecified addresses are rejected, so a craftedWEBHOOK_URLcannot be used to probe internal services. Self-hosted LAN/localhost receivers require the opt-in flag. - The outbound payload is not signed; the receiver has no way to verify it came from this app. If you need authenticity, embed a shared secret in the URL (Discord-style) and validate on the receiver.
- Payloads include sync status, track counts, API/cache metrics, the playlist URL, and truncated error messages - no credentials.
- Timeout 10s, no retries.
Why CodeQL may still flag this
WEBHOOK_URL is operator-supplied configuration, so a SAST tool sees a
user-controlled value reaching an HTTP client and reports SSRF. The
resolve-and-block guard above is the mitigation; the alert is expected and
can be dismissed as used in tests / won't fix (it is the feature's whole
purpose to POST to an operator-chosen URL).
10. Configuration overrides¶
Files under config/ (paths set by CACHE_OVERRIDES_FILE,
TAG_OVERRIDES_FILE, CUSTOM_PLAYLISTS_FILE):
| File | Purpose |
|---|---|
search_overrides.json |
Manual artist\|title → video_id fixes and a blacklist |
tag_overrides.json |
Forced Last.fm tag assignments |
custom_playlists.json |
Custom playlist definitions (tags & artists) |
Tracked in git only as *.example templates. Plaintext JSON; not sensitive on
their own, but they reveal listening preferences.
11. Environment variables¶
The canonical list lives in .env.example and is documented on the
Configuration page. The only sensitive keys are:
LASTFM_API_KEYFLASK_SECRET_KEY(auto-generated)WEBHOOK_URL(if it embeds a token)
Anything else controls behaviour, not access.
.env file permissions
The dashboard writes .env with 0600 (owner read/write only) whenever it
updates settings (web/services/env.py).
On a CLI-only install the sync engine never rewrites .env, so its
permissions are whatever you (or your editor / cp) left them at.
The file is still plaintext - a static analyser will flag
"clear-text storage of sensitive information" because secrets live in it by
design. That alert is expected: there is no key-management layer, and the
threat model already assumes a trusted single-user host.
12. What's excluded from git¶
.gitignore keeps the following out of the repository:
.env,browser.json,.channel/runtime/(entire directory, includinghistory.dbandlastfm_history.db; the legacy/cache/directory is also ignored for pre-rename installs)config/*.jsonwith a!config/*.example(and!config/*.json.example) whitelist - any real override file you drop intoconfig/is ignored by default; only the.exampletemplates are committed*.log,*.bak__pycache__/,.venv*/,dist/,build/,site/,*.egg-info/,node_modules/,.ruff_cache/