Skip to content

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.py via the upstream YTMusic() constructor; orchestrated in src/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 lastfm user and 664 mode (devops/docker-entrypoint.sh). On the host you should keep the file 600 if 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 marked repr=False in src/config.py so 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\|titlevideo_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/missed artist|title, the chosen video_id, resolution source (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 is web or cli.

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 (default 0 = disabled) - deletes any syncs and actions rows older than the cutoff, then runs VACUUM to release the freed pages back to the filesystem. tracks rows are deliberately preserved because they are cumulative lookup state, not audit history.
  • HISTORY_MAX_SIZE_MB (default 0 = unlimited) - if the file is larger than the limit, deletes the oldest 100 actions and 50 syncs per pass until the file fits, then VACUUMs. tracks are 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 via POST /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 require USE_LOCAL_LASTFM_DB=true on 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: TPRT magic 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 via secrets.token_hex(32) and appended to .env under an AUTO-GENERATED section by web/app.py. It persists across restarts; deleting it invalidates any open sessions.
  • Cookies: Flask defaults (HTTPOnly). Mark them Secure by 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 lastfm user; the entrypoint matches its UID/GID to the owner of /app/config so 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_KEY in environment: in compose - it ends up in docker inspect output. Stick with the .env mount.

9. Webhooks

Configured via WEBHOOK_URL, WEBHOOK_EVENTS, and WEBHOOK_ALLOW_PRIVATE (src/webhook.py).

  • SSRF guard. Before sending, the URL must use http/https and - unless WEBHOOK_ALLOW_PRIVATE=true - must resolve to a public IP. Private, loopback, link-local, reserved, multicast, and unspecified addresses are rejected, so a crafted WEBHOOK_URL cannot 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_KEY
  • FLASK_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, including history.db and lastfm_history.db; the legacy /cache/ directory is also ignored for pre-rename installs)
  • config/*.json with a !config/*.example (and !config/*.json.example) whitelist - any real override file you drop into config/ is ignored by default; only the .example templates are committed
  • *.log, *.bak
  • __pycache__/, .venv*/, dist/, build/, site/, *.egg-info/, node_modules/, .ruff_cache/