Skip to content

feat(core): generic accounts, per-account user state and a provider-neutral integration API - #49

Open
tajanthind wants to merge 1 commit into
mainfrom
feat/generic-accounts-user-state-integration-api
Open

tajanthind wants to merge 1 commit into
mainfrom
feat/generic-accounts-user-state-integration-api

Conversation

@tajanthind

Copy link
Copy Markdown
Owner

Adds the generic core infrastructure an external media server needs, without implementing any client protocol in core.

fnack core: accounts + library + generic user state + stable ids
External server (yours): its own client protocol
Clients: connect to your server, never to core

1. Accounts & authentication

  • New ApiToken model: 256-bit random fnack_… tokens, stored only as SHA-256 hashes (prefix kept for display), individually revocable, optional expiry, last_used_at audit.
  • The auth guard now accepts Authorization: Bearer fnack_… / X-API-Token and authenticates the request as that account. Precedence: session → account token → machine key → auth_provider. Passwords stay scrypt-hashed; sessions unchanged.
  • Generic naming throughout — nothing in the domain model is named after any client or product.

2. Per-account user state (services/user_state.py, models.py)

New account-scoped tables: playlists, playlist_items, favorites, ratings, bookmarks, scrobbles, play_queues, play_queue_entries (+ api_tokens).

  • Playlists CRUD with deterministic ordering; favorites/stars (artist|album|track); ratings (1..5); bookmarks (position_ms + comment); scrobble/play history; saved play queue with current_index/position_ms/changed_by.

3. Stable references (no provider coupling)

  • State references fnack's internal artist/album/track ids; provider external_ids are never primary references and never required to resolve saved items.
  • Id columns deliberately carry no FK so library deletions can't cascade away or block state.

4. Concurrency & correctness

  • Positions are unique per owner and compacted to 0..n-1; reordering is a two-phase move (bulk shift, then a single CASE assignment) so no two rows can transiently collide — including SQLAlchemy autoflush. PUT …/items refuses anything but the exact item set (no silent reordering).
  • Deletion-safe: state keeps a snapshot and stays readable (available: false) when the referenced track is hard-deleted; re-linked by ISRC when the recording is re-created under a new id. Hooked into both hard-delete paths (artist delete, sync prune) + relink after sync.
  • Every read/write is scoped to the authenticated account; all referenced ids are validated before persistence.

5. Library query APIs (services/library_query.py)

Provider-neutral artist/album/track search, filters, whitelisted sorting, SQL pagination (LIMIT/OFFSET + COUNT, has_more, limit ≤ 500), direct id lookup, counts. No loading the library into Python; provider identities stay opaque/separately scoped.

6. Core API boundary (services/integration_api.py)

Versioned JSON API at /api/integration/v1 — the integration contract (not the ORM models): meta discovery, token exchange/管理, /me, library endpoints, and the full user-state surface. Only GET /meta and POST /auth/token are public; everything else requires an account identity, and no endpoint accepts an account id from the caller. The machine API key does not authenticate here (no account ⇒ can't honour isolation).

7. Migration

Nine new tables are created additively and idempotently at startup (services/schema_migrations.py); existing library rows and accounts are untouched (asserted by a test that drops the new tables from a populated DB and runs the migration).

8. Docs

docs/integration-api.md (full reference: auth, endpoints, ordering/isolation/deletion semantics, curl examples) + an architecture section. Core docs contain no client/product-specific vocabulary.

Tests — tests/architecture/test_user_state_and_integration_api.py

Two users with fully isolated state and cross-account refusal on every surface (service + HTTP) · playlist CRUD/insert/reorder/compaction/uniqueness · favorites · ratings · bookmarks · scrobble history · saved queue · hard-deletion of a referenced track + ISRC re-link · provider change leaving state intact + provider-neutrality of the new services · SQL paginated library queries · token hashing/revocation/session parity/machine-key refusal · migration on an existing install · no-client-terminology guard.

Verification

  • Architecture suite 28/28; smoke test passes.
  • Real-boot E2E against the full app.py (throwaway config tree, never the live container): 27/27 checks — setup → token exchange → library pagination → playlist ordering → favorites/ratings/bookmarks/scrobbles/queue → cross-account isolation → machine-key refusal → token revocation.

Not in scope (per request)

No server protocol implementation, no client-specific logic, no Arpeggi/AudioMuse modelling, no version bump, no tag, no merge, and no changes to the external server.

…eutral integration API

Adds the reusable primitives an external server/client integration needs,
without implementing any client protocol in core.

Accounts and authentication
- Account-scoped API tokens (models.ApiToken): 256-bit random, `fnack_`
  prefixed, stored ONLY as a SHA-256 hash, individually revocable, optional
  expiry, last-used tracking, prefix kept for display. The guard now accepts
  `Authorization: Bearer fnack_…` / `X-API-Token` and authenticates the
  request AS that account (session → token → machine key → auth_provider).
  Passwords stay scrypt-hashed; sessions unchanged.
- Token issue/list/revoke endpoints; revocation is account-scoped.

Per-account user state (services/user_state.py, models.py)
- First-class, explicitly account-scoped persistence for: playlists and
  playlist items, favorites/stars (artist|album|track), ratings (1..5),
  bookmarks, scrobble/play history, and the saved play queue with its cursor.
- Every read/write filters by the authenticated account; another account's
  object is reported as NotFound so existence is never leaked.
- References are fnack's INTERNAL artist/album/track ids, validated before
  persistence. The id columns deliberately carry no FK: hard-deleting a
  library row must not cascade away or block user state.
- Playlist/queue ordering is deterministic: positions are unique per owner,
  compacted to 0..n-1 after every mutation, and reordering runs as a two-phase
  move (bulk shift out of the 0..n-1 range, then a single CASE assignment) so
  no two rows can transiently share a position — including autoflush.
- Library churn is handled: state rows keep a snapshot (title/artist/album/
  duration/isrc) so they stay readable (`available: false`) when the track is
  gone, and are re-attached by ISRC when the recording is re-created under a
  new internal id (provider replacement). Wired into both hard-delete paths
  (artist delete, discography-sync prune) plus relink after sync.

Library queries (services/library_query.py)
- Provider-neutral artist/album/track search, filters, whitelisted sorting,
  direct id lookup and counts; SQL LIMIT/OFFSET + COUNT so an integration
  never loads the library into memory. Provider identities stay opaque and
  separately scoped.

Integration API (services/integration_api.py)
- Versioned JSON boundary at /api/integration/v1 with meta discovery, token
  exchange, library endpoints and the full user-state surface. It is the
  integration contract — not the SQLAlchemy models. `GET /meta` and
  `POST /auth/token` are the only public endpoints; everything else requires
  an account identity, and no endpoint accepts an account id from the caller.
  The machine API key does not authenticate here (it carries no account).

Migration
- The nine new tables are created additively and idempotently at startup
  (services/schema_migrations.py), so existing installations keep every
  library row and account.

Terminology
- Core carries no client/product-specific vocabulary; legacy client-specific
  wording in plugin docstrings was generalised.

Tests (tests/architecture/test_user_state_and_integration_api.py)
- two accounts with fully isolated state (+ cross-account refusal for every
  surface), playlist CRUD/insert/reorder/compaction and uniqueness, favorites,
  ratings, bookmarks, scrobble history, saved queue, hard-deletion of a
  referenced track + ISRC re-link, provider identity changes leaving state
  intact, provider-neutrality of the new services, SQL-paginated library
  queries, HTTP end-to-end (token auth, session parity, machine-key refusal,
  revocation) and the migration on an existing database.
- Verified: architecture suite 28/28, smoke test passes, and a real-boot E2E
  against the full app.py (throwaway config tree) exercised 27 checks of the
  boundary end to end.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant