This file lives on the postgres-backend branch. It captures branch
state at a pause point so the next session can pick up cleanly. Plan
file: ~/.claude/plans/export-async-function-staritems-snappy-snail.md.
Two pieces of work landed together on this branch:
- Optional Postgres backend alongside SQLite, chosen by URL scheme
(
sqlite://orpostgresql://). User wanted to keep both behind a config flag. - MusicBrainz ID support end-to-end — scan/store/serve.
Plus a bonus: the migration sequence was squashed into a fresh
schema.sql baseline because the user is happy with a fresh install
and 17 micro-migrations were paying no rent.
| # | Commit subject | What it did |
|---|---|---|
| 1 | config: add database_url setting + psycopg3 dependency |
New database_url (sqlite:// or postgresql://) plus a resolved_database_url() helper that synthesises from legacy database_path. psycopg[binary,pool]>=3.1.0 added. ensure_directories() only mkdirs for sqlite. |
| 2 | db/connection: dialect-aware factory + Postgres param translator |
_dialect flag + get_dialect() accessor. Two paths: _new_sqlite_connection (unchanged behaviour) and _new_pg_connection (psycopg3 with dict_row factory). _PgConnection wrapper translates :name → %(name)s on every execute/executemany. |
| 3 | db/errors: expose IntegrityError etc. as a (sqlite3, psycopg) tuple |
Each name is now a tuple of both drivers' exceptions; except accepts that natively. Falls back to single-driver form if psycopg isn't installed. |
| 4 | queries: convert lastrowid usage to RETURNING id |
Three sites (add_music_folder, create_playlist, create_user) switched off cur.lastrowid (psycopg doesn't have it) to INSERT … RETURNING id. |
| 5 | db: replace INSERT OR IGNORE / REPLACE with portable ON CONFLICT |
Three sites updated: star_item, save_play_queue, _migration_003_seed_music_folders. Portable syntax works on both SQLite ≥3.24 and Postgres. |
| 6 | db: squash migrations 1-16 into a single fresh schema.sql baseline |
New comprehensive schema.sql includes every column / index / FTS5 setup from the old migration sequence. migrations.py reduced to 001 (apply schema.sql), 002 (seed admin), 003 (seed music folders). |
| 7 | scanner: extract MusicBrainz IDs and persist them through upserts |
TrackMetadata gains 5 MBID fields, _try_mutagen reads them. upsert_artist / upsert_album / upsert_track accept MBID kwargs with COALESCE-preserve semantics. _parse_one returns them as underscored keys; _commit threads them through. |
| 8 | api: expose musicBrainzId on track, album, and artist responses |
Every track/album/artist SELECT pulls the MBID columns; track_to_subsonic / _album_to_directory_child / get_indexes / get_music_directory emit musicBrainzId when populated. getAlbumInfo[2] and getArtistInfo[2] plumb the real value. /api/artist/{id} exposes it on the envelope and per-album entries. |
| 9 | db: add Postgres schema baseline + dialect-aware loader |
New schema.postgres.sql with INTEGER GENERATED BY DEFAULT AS IDENTITY, CITEXT for case-insensitive columns, BIGINT epoch fields, and a tsvector + GIN + BEFORE INSERT/UPDATE trigger replacing FTS5. _PgConnection.executescript() added so migration 001 works on both backends; _strip_sqlite_isms removes COLLATE NOCASE from incoming SQL (CITEXT makes it redundant). _migration_001_initial picks the schema file by dialect; _current_version branches between sqlite_master and information_schema.tables. |
| 10 | db: branch search3 + maintenance onto dialect for Postgres compatibility |
search3 grows a Postgres branch that queries tracks.search_tsv via @@ websearch_to_tsquery('simple', :query) and ranks by ts_rank. maintenance.run_gc gates _wal_checkpoint, VACUUM, and .db file-size reporting on get_dialect() — Postgres VACUUM is online, flipping autocommit on the underlying psycopg connection. Final 4 positional row accesses converted to named, ? placeholders in maintenance.py converted to :name / generated :p0..pN. |
| 11 | infra: document database_url in config example + add optional Postgres service |
config.example.yaml grows a database_url block with both URL schemes documented. docker-compose.yml gains a postgres service guarded by the postgres Compose profile (default stack still runs SQLite-only). Postgres 18-alpine with tuned config: shared_buffers=256MB, work_mem=21845kB, max_connections=40, effective_cache_size=768MB, synchronous_commit=off. Muse depends_on postgres with required: false so it still works without the profile. |
State: 225 passed, 6 xfailed (identical to main).
All code-side dialect work and packaging is done. The Postgres path should be functional end-to-end. What remains is verification:
- Smoke test on real Postgres —
docker compose --profile postgres up -d, setMUSE_DATABASE_URL=postgresql://muse:$PW@postgres:5432/muse, run a scan, verify search/browse/star/play-queue all work. CITEXT and the tsvector trigger need to actually create cleanly before we trust the schema file. This is the highest-value next step — it'll surface anything the code-side work missed. - pytest Postgres wiring — once the smoke test passes, add a
--postgresflag orPYTEST_POSTGRES_URLenv that re-runs the suite against a Postgres test DB. Probablytestcontainers(auto-managed throwaway container per pytest session) — simpler than wiring a long-lived test DB into CI.
git checkout postgres-backend
cat POSTGRES_BACKEND.md # this file
git log --oneline main..HEADThe docker-compose Postgres profile is now live, so the smoke test is the next concrete step. Recommended:
export POSTGRES_PASSWORD=$(openssl rand -hex 16)
docker compose --profile postgres up -d postgres
# wait for healthy; check with: docker compose ps
# then uncomment MUSE_DATABASE_URL in docker-compose.yml and bring muse up:
docker compose --profile postgres up -d
# tail the logs and watch for the schema apply step:
docker compose logs -f museVerification checklist for the smoke test:
- Migration 001 applies cleanly (no errors about
citext, IDENTITY, tsvector, or the trigger function). - Admin login works (the seed admin path is the same code on both dialects).
- Scan a small library (10-20 tracks) — confirms upsert_artist/ _album/_track all use ON CONFLICT correctly and that RETURNING id returns the right shape.
- Browse the artist/album/track lists in the web UI — confirms COLLATE NOCASE stripping works and CITEXT ordering matches what SQLite produced.
- Star a few items — confirms the polymorphic starred table works.
- Search via the magnifying glass — exercises the tsvector path and the websearch_to_tsquery ranking.
- /api/scan/gc with
?vacuum=true— confirms the online Postgres VACUUM branch works.
- Migrating existing SQLite data to Postgres. User chose fresh install.
- ListenBrainz / sonicSimilarity. Out of scope; separate feature.
- Removing SQLite entirely. User wants both backends.
- We confirmed psycopg3 doesn't accept
:namenatively — that drove the connection wrapper's regex translation approach. Don't switch to positional%sthinking it's "more native"; the named form keeps queries.py readable and the regex is tiny. - The
INTEGER PRIMARY KEY AUTOINCREMENTvsIDENTITYdifference is why we need separate schema files rather than a single portable one. SQLite has special rowid semantics that don't map cleanly. - COLLATE NOCASE was resolved via option 2 from the original plan:
CITEXT on Postgres side, leave SQLite alone, and have the connection
wrapper strip the now-redundant
COLLATE NOCASEclauses from incoming SQL via regex. Zero changes toqueries.pyneeded. - psycopg3's
dict_rowfactory doesn't support positional indexing (row[0]raises KeyError because it's looking for a column named0). Four sites in queries/migrations/maintenance had to be aliased (SELECT COUNT(*) AS n) and switched to named access. - FTS5 → tsvector turned out to be smaller than estimated — under an evening — because the trigger model is a near-direct port: BEFORE INSERT/UPDATE on tracks, look up artist/album names from joined rows, assemble a weighted tsvector. The 3-4 evening estimate assumed maintaining a parallel side-table; storing on tracks itself is simpler.
- VACUUM on Postgres is online (no exclusive lock) when invoked
without FULL, so the maintenance UI's "compact database" button is
actually cheaper on Postgres than SQLite. We toggle
autocommit=Truefor the duration because VACUUM can't run inside a transaction.