diff --git a/app.py b/app.py index d92e0b9..1584480 100644 --- a/app.py +++ b/app.py @@ -117,6 +117,11 @@ def plugin_slot(slot_name: str, **context_data): from services.accounts import build_accounts_blueprint as _build_accounts_blueprint app.register_blueprint(_build_accounts_blueprint()) +# Generic integration API (accounts + library + per-account user state) for +# external server/client systems. Protocol-agnostic: JSON in, JSON out. +from services.integration_api import build_integration_blueprint as _build_integration_blueprint +app.register_blueprint(_build_integration_blueprint()) + _db_dir = Path(os.environ.get("CONFIG_DIR", "/config")) if not _db_dir.exists() and not os.environ.get("SQLALCHEMY_DATABASE_URI"): @@ -498,6 +503,12 @@ def _sync_artist_discography_background(artist_id: int, external_id: str, option if not artist: return + # Track rows created by this sync: after the prune below, per-account + # user state is re-attached to them by ISRC (a provider change can + # replace a recording's internal id without the user losing a star, + # rating, bookmark or playlist entry). + new_track_ids: list[int] = [] + if disco.get("artist_image") and not artist.image_url: artist.image_url = disco["artist_image"] # Provenance: the artist's external identity belongs to the @@ -561,6 +572,7 @@ def _sync_artist_discography_background(artist_id: int, external_id: str, option ) db.session.add(track) db.session.flush() + new_track_ids.append(track.id) else: if t.get("isrc") and not track.isrc: track.isrc = t["isrc"] @@ -584,12 +596,34 @@ def _sync_artist_discography_background(artist_id: int, external_id: str, option has_downloaded = any(t.is_downloaded for t in existing_alb.tracks.all()) if not has_downloaded: logger.info("[SYNC] Pruning stale/misattributed album '%s' (id=%d) for artist '%s'", existing_alb.name, existing_alb.id, artist.name) - for t in existing_alb.tracks.all(): + stale_tracks = existing_alb.tracks.all() + # Snapshot per-account user state before the rows go: + # state keeps its snapshot and is re-linked by ISRC if + # the provider re-creates the recording later. + try: + from services import user_state as _user_state + from services.library_query import track_json as _track_json + _ids = [t.id for t in stale_tracks] + if _ids: + _user_state.snapshot_referenced_tracks(_ids) + except Exception: + logger.exception("[USER-STATE] snapshot before sync prune failed") + for t in stale_tracks: DownloadJob.query.filter_by(track_id=t.id).delete() db.session.delete(t) db.session.delete(existing_alb) db.session.flush() + if new_track_ids: + try: + from services import user_state as _user_state + _relinked = _user_state.relink_tracks(new_track_ids) + if _relinked: + logger.info("[USER-STATE] Re-attached %d user-state entr(ies) " + "to re-created tracks after sync", _relinked) + except Exception: + logger.exception("[USER-STATE] relink after sync failed") + artist.sync_status = "ready" artist.sync_error = None artist.last_synced_at = datetime.now(timezone.utc) @@ -797,6 +831,19 @@ def api_delete_artist(artist_id): name = artist.name del_files = request.args.get("delete_files", "false").lower() == "true" + # Per-account user state references tracks by internal id and must + # outlive the library row: snapshot it before the delete cascades, so + # playlists/favorites/ratings/bookmarks/history stay readable (and can + # be re-linked by ISRC if the track is ever re-created). + try: + from services import user_state as _user_state + _track_ids = [row[0] for row in db.session.query(Track.id) + .filter(Track.artist_id == artist.id).all()] + if _track_ids: + _user_state.snapshot_referenced_tracks(_track_ids) + except Exception: + logger.exception("[USER-STATE] snapshot before artist delete failed") + db.session.delete(artist) db.session.commit() diff --git a/docs/architecture.md b/docs/architecture.md index 0deae3a..a36ab52 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -180,6 +180,40 @@ initial **admin** account is created; `/login` then guards everything else. - `/health`, `/static` and the Socket.IO transport stay open (probes/UX); every data-bearing route requires an identity. +## Accounts, user state & integrations + +fnack core owns three generic things that any external server, client or +automation can build on — and nothing protocol-specific: + +- **Accounts** (`services/accounts.py`): multiple independent users, scrypt + password hashes, session login for the web UI, and account-scoped **API + tokens** stored only as SHA-256 hashes. +- **Library queries** (`services/library_query.py`): provider-neutral artist / + album / track search, filters, sorting, direct id lookup and counts, always + SQL-paginated (an integration never loads the library into memory). +- **Per-account user state** (`services/user_state.py`): playlists with + deterministic, unique per-owner ordering, favorites, ratings, bookmarks, + scrobble history and the saved play queue. + +Interaction rules: + +- Everything user-state is scoped to an account and enforced in the service + layer; cross-account access is reported as "not found" rather than leaking + existence. +- User state references the **internal** `Artist`/`Album`/`Track` ids. The id + columns carry no foreign key on purpose: hard-deleting a library row must + never cascade away or block user state. Rows keep a snapshot and are + re-attached by ISRC if the recording is re-created (e.g. by a different + provider) — see "State outlives the library" in docs/integration-api.md. +- Metadata providers stay separately scoped and opaque: `provider_id` + + `external_id` identify an object *to that provider only* and are never a + primary reference or a requirement for resolving saved state. +- The integration boundary is the versioned HTTP API in + `services/integration_api.py` (`/api/integration/v1`, see + docs/integration-api.md). External systems consume JSON over HTTP with an + account token; they do not import core modules and core does not implement + their protocols. SQLAlchemy models are not the integration contract. + ## Essential vs optional packaging The Docker image ships **only** the essential plugins — the set required for diff --git a/docs/integration-api.md b/docs/integration-api.md new file mode 100644 index 0000000..3b69e48 --- /dev/null +++ b/docs/integration-api.md @@ -0,0 +1,227 @@ +# Integration API (v1) — accounts, library, and per-account user state + +fnack core exposes a small, **protocol-agnostic** HTTP API that external +systems can build on: a server product that speaks its own client protocol, a +sync tool, a desktop client, or a script. Core implements no media-server +protocol itself — it provides the reusable primitives (accounts, library +queries, per-account user state) and the external system decides what to +expose to its own clients. + +Base path: `/api/integration/v1` — see `GET /api/integration/v1/meta` for the +machine-readable capability list. + +``` +external server (its own client protocol) ─┐ +external tools / scripts ─┼─► fnack core integration API +any HTTP client ─┘ accounts + library + user state +``` + +## Authentication + +Every endpoint needs an **account identity** except `POST /auth/token` and +`GET /meta`. Two credential types are accepted: + +| Credential | How it is sent | Notes | +|---|---|---| +| Account session | `Cookie: session=…` | What the fnack web UI uses (form login at `/login`). | +| Account API token | `Authorization: Bearer fnack_…` or `X-API-Token: fnack_…` | For programmatic clients. **Recommended for integrations.** | + +A token authenticates **as its account**: the API derives the account from the +credential and never accepts an account id from the caller. Tokens are +generated with 256 bits of randomness, prefixed with `fnack_`, and stored +**only as a SHA-256 hash** — the plaintext is returned once at creation and +can never be recovered from the database. + +The machine-level API key used elsewhere in fnack does **not** authenticate +here: it carries no account and therefore cannot honour per-account isolation. + +### Get a token + +```bash +curl -sX POST http://localhost:4688/api/integration/v1/auth/token \ + -H 'Content-Type: application/json' \ + -d '{"username": "alice", "password": "…", "label": "living-room"}' +# → 201 {"token": "fnack_…", "token_type": "bearer", "account": {...}, …} +``` + +```bash +TOKEN=fnack_… +curl -s http://localhost:4688/api/integration/v1/me -H "Authorization: Bearer $TOKEN" +``` + +### Manage tokens + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/tokens` | List this account's tokens (never the secret). | +| `POST` | `/tokens` | Create one (`label`, `expires_in_days`). | +| `DELETE` | `/tokens/` | Revoke one (another account's id → `404`). | +| `GET` | `/me` | Identity + this account's state counts + library totals. | + +## Library queries + +Provider-neutral: no metadata provider is required, and provider identities +(`provider_id`, `external_id`) are returned for display only — they are +opaque, separately scoped values, never a primary reference. + +| Method | Path | Query parameters | +|---|---|---| +| `GET` | `/library/stats` | — (artist/album/track/downloaded counts) | +| `GET` | `/library/artists` | `q`, `offset`, `limit`, `sort` (`name`,`created`,`id`), `order` | +| `GET` | `/library/artists/` | — (with album/track counts) | +| `GET` | `/library/albums` | `q`, `artist_id`, `offset`, `limit`, `sort` (`name`,`year`,`created`,`id`), `order` | +| `GET` | `/library/albums/` | — (with track/downloaded counts) | +| `GET` | `/library/tracks` | `q`, `album_id`, `artist_id`, `downloaded`, `status`, `offset`, `limit`, `sort` (`title`,`duration`,`track_number`,`created`,`id`), `order` | +| `GET` | `/library/tracks/` | — (includes `stream_path` / `stream_ready` for serving audio) | + +`q` on tracks matches the track title, its album name **or** its artist name, +so a caller does not need to know which level to search. Every list response +is `{"items": [...], "total": n, "offset": …, "limit": …, "has_more": bool}` +and is paginated in SQL (`LIMIT`/`OFFSET` + `COUNT`) — integrations never load +the library into memory. `limit` is capped at 500 per request. + +## Per-account user state + +Every object below is owned by exactly one account. Reads and writes are +scoped to the authenticated account; an id belonging to another account +returns `404` (existence is never leaked). All references are fnack's +**internal** `Artist`/`Album`/`Track` ids, validated before anything is +persisted (unknown ids → `400`). + +### Playlists + +| Method | Path | Body / notes | +|---|---|---| +| `GET` | `/playlists` | `offset`, `limit`; each entry has `item_count`. | +| `POST` | `/playlists` | `{"name", "comment"?, "track_ids"?}` → `201`. | +| `GET` | `/playlists/` | Full ordered items (`offset`/`limit` optional). | +| `PATCH` | `/playlists/` | `{"name"?, "comment"?}`. | +| `DELETE` | `/playlists/` | Removes the playlist and its items. | +| `POST` | `/playlists//items` | `{"track_ids": [...], "position"?}` → append or insert. | +| `PUT` | `/playlists//items` | `{"item_ids": [...]}` — the exact full order. | +| `DELETE` | `/playlists//items/` | Removes one entry. | + +**Ordering is deterministic.** Each item row carries a `position` that is +unique per playlist and always compacted to `0..n-1`; reads are ordered by +`(position, id)`. Reordering is a two-phase move inside one transaction, so +two rows can never transiently share a position; and `PUT …/items` is refused +(`400`) unless it lists exactly the playlist's item ids — a partial or +duplicated order is never guessed. + +Each item also carries a **snapshot** (`title`, `artist_name`, `album_name`, +`duration`, `isrc`) plus `available`: if the referenced track is not in the +library any more, the entry still renders from its snapshot with +`available: false` instead of disappearing. + +### Favorites / stars + +| Method | Path | Notes | +|---|---|---| +| `GET` | `/favorites` | `type` (`artist`\|`album`\|`track`), `offset`, `limit`. | +| `GET` | `/favorites//` | `{"starred": bool}` | +| `PUT` | `/favorites//` | Star (idempotent). | +| `DELETE` | `/favorites//` | Unstar. | + +### Ratings + +| Method | Path | Notes | +|---|---|---| +| `GET` | `/ratings` | `type`, `offset`, `limit`. | +| `GET` | `/ratings//` | `{"rating": 1..5 \| null}` | +| `PUT` | `/ratings//` | `{"rating": 1..5}` (other values → `400`). | +| `DELETE` | `/ratings//` | Clears the rating. | + +### Bookmarks + +| Method | Path | Notes | +|---|---|---| +| `GET` | `/bookmarks` | `offset`, `limit`. | +| `GET` | `/bookmarks//` | `404` when unset. | +| `PUT` | `/bookmarks//` | `{"position_ms": int, "comment"?}`; `type` is `track`\|`album`. | +| `DELETE` | `/bookmarks//` | Removes it. | + +### Scrobble / play history + +| Method | Path | Notes | +|---|---|---| +| `POST` | `/scrobbles` | `{"track_id", "played_at"?, "submission"?}` → `201`. | +| `GET` | `/scrobbles` | `offset`, `limit`, `since` (ISO-8601), newest first. | +| `GET` | `/scrobbles/top` | Most-played tracks (`limit`). | + +### Saved play queue + +| Method | Path | Notes | +|---|---|---| +| `GET` | `/queue` | Entries + `current_index`, `position_ms`, `changed_by`, `changed_at`. | +| `PUT` | `/queue` | `{"track_ids": [...], "current_index"?, "position_ms"?, "changed_by"?}` — replaces atomically. | +| `DELETE` | `/queue` | Clears the queue. | + +### Summary + +`GET /state/summary` → `{"playlists", "favorites", "ratings", "bookmarks", +"scrobbles", "queue_items"}` for the authenticated account. + +## State outlives the library + +User state is deliberately **not** foreign-keyed to library rows: + +* Metadata or provider changes to a track (title, duration, `provider_id`, + `external_id`) are invisible to user state — it references the internal id. +* If a track row is **hard-deleted** (a discography sync prunes a release that + the serving provider no longer lists, or an artist is deleted), fnack + snapshots the affected state first. Playlist entries, queue entries, + favorites, ratings, bookmarks and history stay readable and are marked + `available: false`. +* If the same recording is re-created later (typically by a different + provider, under a new internal id), state is **re-attached automatically by + ISRC** — the users' stars, ratings, bookmarks, playlist and queue entries + follow the recording rather than being lost. + +## Security model + +* Ownership is enforced in the service layer for every operation; the HTTP + layer never accepts an account id from the caller. +* Cross-account access returns `404`, so one account cannot probe another's + data by id. +* All referenced library ids are validated before persistence. +* Tokens are stored hashed, are revocable individually, and can carry an + expiry; `last_used_at` is recorded for auditing. +* The web UI's session guard and CSRF rules also apply to this API when a + session cookie is used; token requests carry no cookie and are therefore not + subject to cross-site request forgery. + +## Migration + +The user-state and token tables (`playlists`, `playlist_items`, `favorites`, +`ratings`, `bookmarks`, `scrobbles`, `play_queues`, `play_queue_entries`, +`api_tokens`) are created **additively and idempotently** at startup +(`services/schema_migrations.py`), so an existing installation keeps every +library row and every account and gains the new tables on the next boot. + +## Example session + +```bash +BASE=http://localhost:4688/api/integration/v1 +TOKEN=$(curl -sX POST $BASE/auth/token -H 'Content-Type: application/json' \ + -d '{"username":"alice","password":"…"}' | jq -r .token) +AUTH="Authorization: Bearer $TOKEN" + +# search the library, page it +curl -s "$BASE/library/tracks?q=live&limit=25&sort=title" -H "$AUTH" + +# create a playlist from the first result +TRACK=$(curl -s "$BASE/library/tracks?limit=1" -H "$AUTH" | jq -r '.items[0].id') +PL=$(curl -sX POST $BASE/playlists -H "$AUTH" -H 'Content-Type: application/json' \ + -d "{\"name\":\"Road trip\",\"track_ids\":[$TRACK]}" | jq -r .id) + +# star it, rate it, remember where you are, save the queue +curl -sX PUT "$BASE/favorites/track/$TRACK" -H "$AUTH" +curl -sX PUT "$BASE/ratings/track/$TRACK" -H "$AUTH" -H 'Content-Type: application/json' -d '{"rating":5}' +curl -sX PUT "$BASE/bookmarks/track/$TRACK" -H "$AUTH" -H 'Content-Type: application/json' -d '{"position_ms":42000}' +curl -sX PUT "$BASE/queue" -H "$AUTH" -H 'Content-Type: application/json' \ + -d "{\"track_ids\":[$TRACK],\"current_index\":0,\"changed_by\":\"road-trip\"}" + +# record a play +curl -sX POST "$BASE/scrobbles" -H "$AUTH" -H 'Content-Type: application/json' \ + -d "{\"track_id\":$TRACK}" +``` diff --git a/models.py b/models.py index 245841a..018883d 100644 --- a/models.py +++ b/models.py @@ -205,3 +205,230 @@ class User(db.Model): password_hash = db.Column(db.String(256), nullable=False) role = db.Column(db.String(16), default="user", nullable=False) # admin | user created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + +# --------------------------------------------------------------------------- +# Accounts: programmatic credentials +# --------------------------------------------------------------------------- + +class ApiToken(db.Model): + """Account-scoped API credential for programmatic clients. + + Only the SHA-256 hash of the token is stored (`token_hash`), so a leaked + database does not yield usable credentials; `prefix` keeps the first + characters for identification in listings. A token belongs to exactly one + account and inherits that account's identity on every request. + + Deliberately generic: this is fnack's own credential type, not any + particular client protocol's. + """ + + __tablename__ = "api_tokens" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + label = db.Column(db.String(128), nullable=True) + prefix = db.Column(db.String(16), nullable=False, index=True) + token_hash = db.Column(db.String(64), nullable=False, unique=True, index=True) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + last_used_at = db.Column(db.DateTime, nullable=True) + expires_at = db.Column(db.DateTime, nullable=True) + revoked_at = db.Column(db.DateTime, nullable=True) + + user = db.relationship("User", backref=db.backref("api_tokens", cascade="all, delete-orphan")) + + +# --------------------------------------------------------------------------- +# Per-account user state +# +# Every table below is explicitly scoped by `user_id` and references the +# library through fnack's INTERNAL Artist/Album/Track ids. Those id columns +# intentionally carry NO foreign-key constraint: user state must outlive +# library churn (a track row deleted by a discography sync, a provider +# replaced) instead of cascading away or blocking the delete. Each row also +# keeps a small snapshot (title/artist/album/duration/isrc) so it stays +# readable while the referenced object is not resolvable, and can be +# re-attached to a re-created object through its ISRC. +# --------------------------------------------------------------------------- + +class Playlist(db.Model): + """An account-owned, ordered list of library tracks.""" + + __tablename__ = "playlists" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + name = db.Column(db.String(256), nullable=False) + comment = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = db.Column( + db.DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + items = db.relationship( + "PlaylistItem", + back_populates="playlist", + cascade="all, delete-orphan", + order_by="PlaylistItem.position", + ) + + +class PlaylistItem(db.Model): + """One track entry inside a playlist. + + `position` is unique per playlist (deterministic ordering, no duplicate or + conflicting positions) and always compacted to 0..n-1 by the service. + """ + + __tablename__ = "playlist_items" + __table_args__ = ( + db.UniqueConstraint("playlist_id", "position", name="uq_playlist_items_position"), + ) + + id = db.Column(db.Integer, primary_key=True) + playlist_id = db.Column(db.Integer, db.ForeignKey("playlists.id"), nullable=False, index=True) + position = db.Column(db.Integer, nullable=False, index=True) + track_id = db.Column(db.Integer, nullable=True, index=True) # no FK: state outlives tracks + # Snapshot of the referenced track at write time (also the re-link key). + title = db.Column(db.String(512), nullable=True) + artist_name = db.Column(db.String(512), nullable=True) + album_name = db.Column(db.String(512), nullable=True) + duration = db.Column(db.Float, nullable=True) + isrc = db.Column(db.String(64), nullable=True, index=True) + added_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + playlist = db.relationship("Playlist", back_populates="items") + + +class Favorite(db.Model): + """A starred library object (artist, album or track) for one account.""" + + __tablename__ = "favorites" + __table_args__ = ( + db.UniqueConstraint("user_id", "item_type", "item_id", name="uq_favorites_item"), + ) + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + item_type = db.Column(db.String(16), nullable=False, index=True) # artist | album | track + item_id = db.Column(db.Integer, nullable=False, index=True) + name = db.Column(db.String(512), nullable=True) # snapshot for display + isrc = db.Column(db.String(64), nullable=True, index=True) # tracks only; re-link key + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + +class Rating(db.Model): + """A 1..5 rating of a library object for one account.""" + + __tablename__ = "ratings" + __table_args__ = ( + db.UniqueConstraint("user_id", "item_type", "item_id", name="uq_ratings_item"), + ) + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + item_type = db.Column(db.String(16), nullable=False, index=True) # artist | album | track + item_id = db.Column(db.Integer, nullable=False, index=True) + rating = db.Column(db.Integer, nullable=False) # 1..5 + isrc = db.Column(db.String(64), nullable=True, index=True) # tracks only; re-link key + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = db.Column( + db.DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + +class Bookmark(db.Model): + """A per-account playback position inside a track (or album).""" + + __tablename__ = "bookmarks" + __table_args__ = ( + db.UniqueConstraint("user_id", "item_type", "item_id", name="uq_bookmarks_item"), + ) + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + item_type = db.Column(db.String(16), nullable=False, index=True) # track | album + item_id = db.Column(db.Integer, nullable=False, index=True) + position_ms = db.Column(db.Integer, nullable=False, default=0) + comment = db.Column(db.Text, nullable=True) + title = db.Column(db.String(512), nullable=True) # snapshot + isrc = db.Column(db.String(64), nullable=True, index=True) # tracks only; re-link key + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = db.Column( + db.DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + +class Scrobble(db.Model): + """One playback event recorded for an account (play history).""" + + __tablename__ = "scrobbles" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + track_id = db.Column(db.Integer, nullable=True, index=True) # no FK: history outlives tracks + # Snapshot so history stays meaningful after library churn. + title = db.Column(db.String(512), nullable=True) + artist_name = db.Column(db.String(512), nullable=True) + album_name = db.Column(db.String(512), nullable=True) + duration = db.Column(db.Float, nullable=True) + isrc = db.Column(db.String(64), nullable=True, index=True) + played_at = db.Column(db.DateTime, nullable=False, index=True, + default=lambda: datetime.now(timezone.utc)) + submission = db.Column(db.Boolean, nullable=False, default=True) # reported vs started + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + +class PlayQueue(db.Model): + """The account's saved play queue (one per account) with its cursor.""" + + __tablename__ = "play_queues" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, unique=True, index=True) + current_index = db.Column(db.Integer, nullable=False, default=0) + position_ms = db.Column(db.Integer, nullable=False, default=0) + changed_by = db.Column(db.String(128), nullable=True) + changed_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = db.Column( + db.DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + entries = db.relationship( + "PlayQueueEntry", + back_populates="queue", + cascade="all, delete-orphan", + order_by="PlayQueueEntry.position", + ) + + +class PlayQueueEntry(db.Model): + """One track in a saved queue; `position` is unique per account.""" + + __tablename__ = "play_queue_entries" + __table_args__ = ( + db.UniqueConstraint("user_id", "position", name="uq_play_queue_entries_position"), + ) + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + queue_id = db.Column(db.Integer, db.ForeignKey("play_queues.id"), nullable=False, index=True) + position = db.Column(db.Integer, nullable=False, index=True) + track_id = db.Column(db.Integer, nullable=True, index=True) # no FK: state outlives tracks + title = db.Column(db.String(512), nullable=True) + artist_name = db.Column(db.String(512), nullable=True) + album_name = db.Column(db.String(512), nullable=True) + duration = db.Column(db.Float, nullable=True) + isrc = db.Column(db.String(64), nullable=True, index=True) + added_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + queue = db.relationship("PlayQueue", back_populates="entries") diff --git a/plugins/base.py b/plugins/base.py index 19afe11..bffb856 100644 --- a/plugins/base.py +++ b/plugins/base.py @@ -228,8 +228,8 @@ def status(self) -> dict: class ServerExtensionPlugin(PluginBase): - """For plugins that need entirely new HTTP routes, e.g. a Subsonic- - compatible API surface so third-party Subsonic clients can talk to fnack + """For plugins that need entirely new HTTP routes, e.g. a server API + surface so third-party clients can talk to fnack directly.""" @abstractmethod diff --git a/plugins/context.py b/plugins/context.py index e40bc4f..da7cce0 100644 --- a/plugins/context.py +++ b/plugins/context.py @@ -102,14 +102,14 @@ def list_missing_tracks(self, limit: int = 500) -> list[dict]: return [{"id": t.id, "title": t.title, "isrc": t.isrc} for t in rows] def list_artists(self) -> list[dict]: - """All artists (for Subsonic-style server extensions).""" + """All artists (for server-extension plugins).""" self._check("library:read") from models import Artist return [{"id": a.id, "name": a.name, "image_url": a.image_url} for a in Artist.query.order_by(Artist.name).all()] def list_albums(self, artist_id: Optional[int] = None, limit: int = 500) -> list[dict]: - """Albums, optionally filtered by artist (Subsonic album list).""" + """Albums, optionally filtered by artist (server album listing).""" self._check("library:read") from models import Album q = Album.query @@ -121,7 +121,7 @@ def list_albums(self, artist_id: Optional[int] = None, limit: int = 500) -> list "is_downloaded": a.is_downloaded} for a in rows] def list_tracks(self, album_id: Optional[int] = None, limit: int = 1000) -> list[dict]: - """Tracks, optionally filtered by album (Subsonic song list).""" + """Tracks, optionally filtered by album (server track listing).""" self._check("library:read") from models import Track q = Track.query @@ -156,7 +156,7 @@ def set_setting(self, key: str, value) -> None: def get_api_key(self) -> str: """The configured M2M API key ('' if unset). Exposed so server- - extension plugins (e.g. Subsonic) can authenticate clients against + server-extension plugins can authenticate clients against the same key without touching models directly.""" self._check("library:read") return self.get_setting("api_key", "").strip() diff --git a/services/accounts.py b/services/accounts.py index cc3c8d1..76675b9 100644 --- a/services/accounts.py +++ b/services/accounts.py @@ -26,18 +26,30 @@ from __future__ import annotations +import hashlib import re +import secrets +from datetime import datetime, timedelta, timezone from flask import Blueprint, g, jsonify, redirect, render_template, request, session from werkzeug.security import check_password_hash, generate_password_hash from models import db +# Account API tokens are opaque bearer credentials: `fnack_` + 32 random +# bytes, URL-safe. Only their SHA-256 hash is stored (see ApiToken), so the +# database never contains a usable credential. The prefix is kept for display. +TOKEN_PREFIX = "fnack_" + MIN_PASSWORD_LENGTH = 8 _USERNAME_RE = re.compile(r"^[A-Za-z0-9_.\-]{3,64}$") # Paths that never need an account / session. -OPEN_PATHS = {"/health", "/login", "/logout", "/setup", "/favicon.ico", "/socket.io"} +# /api/integration/v1/auth/token is the token-exchange endpoint for +# programmatic clients: it authenticates credentials itself and is how a +# client obtains an account token in the first place. +OPEN_PATHS = {"/health", "/login", "/logout", "/setup", "/favicon.ico", "/socket.io", + "/api/integration/v1/auth/token", "/api/integration/v1/meta"} UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"} @@ -103,6 +115,120 @@ def is_admin() -> bool: return bool(user and user.role == "admin") +# -------------------------------------------------------------------------- +# account API tokens (programmatic clients) +# -------------------------------------------------------------------------- + +def _hash_token(plaintext: str) -> str: + return hashlib.sha256(plaintext.encode("utf-8")).hexdigest() + + +def create_api_token(user, label: str | None = None, expires_in_days=None): + """Mint a token for ``user``. + + Returns ``(plaintext, row)`` — the plaintext is shown to the caller once + and never stored (only its hash is), so it cannot be recovered later. + """ + from models import ApiToken + if getattr(user, "id", None) is None: + raise ValueError("a persisted account is required") + if label is not None: + label = str(label)[:128] or None + expires_at = None + if expires_in_days: + try: + days = int(expires_in_days) + except (TypeError, ValueError): + raise ValueError("expires_in_days must be an integer") + if days <= 0: + raise ValueError("expires_in_days must be > 0") + expires_at = datetime.now(timezone.utc) + timedelta(days=days) + plaintext = TOKEN_PREFIX + secrets.token_urlsafe(32) + row = ApiToken( + user_id=user.id, + label=label, + prefix=plaintext[: len(TOKEN_PREFIX) + 8], + token_hash=_hash_token(plaintext), + expires_at=expires_at, + ) + db.session.add(row) + db.session.commit() + return plaintext, row + + +def verify_api_token(plaintext: str): + """Resolve a bearer token to its account, or None. + + Rejects unknown, revoked and expired tokens; records last use. Lookup is + by hash (unique index) so a database read cannot be replayed as a token. + """ + from models import ApiToken, User + if not plaintext or not plaintext.startswith(TOKEN_PREFIX): + return None + row = ApiToken.query.filter_by(token_hash=_hash_token(plaintext)).first() + if row is None or row.revoked_at is not None: + return None + now = datetime.now(timezone.utc) + if row.expires_at is not None: + expires = row.expires_at + if expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + if expires <= now: + return None + user = db.session.get(User, row.user_id) + if user is None: + return None + row.last_used_at = now + db.session.commit() + return user + + +def token_json(row) -> dict: + """Safe listing view of a token (never the secret).""" + return { + "id": row.id, + "label": row.label, + "prefix": row.prefix, + "created_at": row.created_at.isoformat() if row.created_at else None, + "last_used_at": row.last_used_at.isoformat() if row.last_used_at else None, + "expires_at": row.expires_at.isoformat() if row.expires_at else None, + "revoked_at": row.revoked_at.isoformat() if row.revoked_at else None, + } + + +def list_api_tokens(user) -> list: + """This account's tokens, without secrets.""" + from models import ApiToken + rows = (ApiToken.query.filter_by(user_id=user.id) + .order_by(ApiToken.created_at.desc(), ApiToken.id.desc()).all()) + return [token_json(r) for r in rows] + + +def revoke_api_token(user, token_id) -> bool: + """Revoke one of THIS account's tokens (another account's id is refused).""" + from models import ApiToken + try: + token_id = int(token_id) + except (TypeError, ValueError): + return False + row = ApiToken.query.filter_by(id=token_id, user_id=user.id).first() + if row is None: + return False + row.revoked_at = datetime.now(timezone.utc) + db.session.commit() + return True + + +def bearer_token() -> str | None: + """The account token presented by this request, if any.""" + header = (request.headers.get("Authorization") or "").strip() + if header.lower().startswith("bearer "): + header = header[7:].strip() + candidate = request.headers.get("X-API-Token") or header or "" + candidate = candidate.strip() + return candidate or None + + # -------------------------------------------------------------------------- # before_request guard # -------------------------------------------------------------------------- @@ -110,11 +236,15 @@ def is_admin() -> bool: def auth_guard(): """Whole-app lockdown (registered as app.before_request). - Identity precedence: session account > M2M API key > auth_provider - plugins (reverse-proxy SSO). When no accounts exist at all, only /setup - (+ the always-open paths) is reachable until an admin account is created. - Cross-site state changes (CSRF) are refused for session-authenticated - requests. + Identity precedence: session account > account API token (Bearer / + X-API-Token) > M2M API key > auth_provider plugins (reverse-proxy SSO). + When no accounts exist at all, only /setup (+ the always-open paths) is + reachable until an admin account is created. Cross-site state changes + (CSRF) are refused for session-authenticated requests. + + An account token authenticates as THAT account (g.current_user + + g.fnack_account_id), so account-scoped APIs can rely on the identity and + never accept an account id from the caller. """ path = request.path if any(path == p or path.startswith(p + "/") for p in ("/health", "/static")): @@ -125,6 +255,19 @@ def auth_guard(): from models import AppSetting, User no_accounts = User.query.count() == 0 + # Account API token (programmatic clients) — account-scoped identity. + # Checked before the machine key because a token IS an account. + presented = bearer_token() + if presented and presented.startswith(TOKEN_PREFIX): + token_user = verify_api_token(presented) + if token_user is not None: + g.fnack_user = token_user.username + g.fnack_auth = "token" + g.current_user = token_user + g._fnack_current_user = token_user # same cache current_user() uses + g.fnack_account_id = token_user.id + return None # not cookie-based: no cross-site request forgery risk + # Optional M2M API key (machine clients) — same identity as before. api_key = request.headers.get("X-API-Key") or ( request.headers.get("Authorization") or "").replace("Bearer ", "") @@ -141,6 +284,7 @@ def auth_guard(): g.fnack_user = user.username g.fnack_auth = "session" g.current_user = user + g.fnack_account_id = user.id if (request.method in UNSAFE_METHODS and request.headers.get("Origin") and not _same_origin(request.headers.get("Origin"), request.host_url)): diff --git a/services/integration_api.py b/services/integration_api.py new file mode 100644 index 0000000..1b1487e --- /dev/null +++ b/services/integration_api.py @@ -0,0 +1,422 @@ +"""Generic integration API (v1): the stable HTTP boundary for external +systems that need fnack's accounts, library and per-account user state. + +This is deliberately protocol-agnostic — it speaks plain JSON and knows +nothing about any particular server or client protocol. A server product +implements its own protocol on top of these primitives; a plugin never needs +to import core internals. + +Auth: an account session cookie **or** an account API token +(``Authorization: Bearer fnack_…`` / ``X-API-Token``). ``POST /auth/token`` +(credential exchange) and ``GET /meta`` (capability discovery) are the only +public endpoints; every other endpoint requires an account identity, and every user-state +read/write is scoped to that identity — no endpoint accepts an account id +from the caller. The machine-level API key does NOT authenticate here: it +carries no account and could not honour per-account isolation. + +Errors: ``400`` invalid request/unknown library id, ``401`` unauthenticated, +``403`` forbidden, ``404`` not found OR not owned by this account (existence +is never leaked across accounts). +""" + +from __future__ import annotations + +from flask import Blueprint, jsonify, request + +from services import library_query, user_state +from services.accounts import ( + create_api_token, + current_user, + list_api_tokens, + revoke_api_token, + token_json, + verify_password, +) + +API_NAME = "fnack-integration" +API_VERSION = "v1" +PUBLIC_PATHS = ("/api/integration/v1/auth/token",) + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +def _account(): + """The authenticated account for this request (session or token).""" + return current_user() + + +def _error(message, status=400): + return jsonify({"error": message}), status + + +def _body() -> dict: + data = request.get_json(silent=True) + return data if isinstance(data, dict) else {} + + +def _account_json(user) -> dict: + return { + "id": user.id, + "username": user.username, + "role": user.role, + "created_at": user.created_at.isoformat() if user.created_at else None, + } + + +def _int_arg(name, default=None): + raw = request.args.get(name, default) + if raw is None: + return None + try: + return int(raw) + except (TypeError, ValueError): + raise user_state.ValidationError(f"{name} must be an integer") + + +def build_integration_blueprint() -> Blueprint: + bp = Blueprint("integration_api", __name__, url_prefix="/api/integration/v1") + + @bp.errorhandler(user_state.NotFound) + def _not_found(exc): + return _error(str(exc), 404) + + @bp.errorhandler(user_state.ValidationError) + def _invalid(exc): + return _error(str(exc), 400) + + @bp.errorhandler(library_query.LibraryQueryError) + def _bad_query(exc): + return _error(str(exc), 400) + + # -- meta --------------------------------------------------------------- + + @bp.route("/meta", methods=["GET"]) + def meta(): + return jsonify({ + "name": API_NAME, + "version": API_VERSION, + "auth": { + "types": ["session_cookie", "bearer_token"], + "token_header": "Authorization: Bearer fnack_…", + "token_endpoint": PUBLIC_PATHS[0], + }, + "features": [ + "accounts", "library_search", "library_pagination", + "playlists", "favorites", "ratings", "bookmarks", + "scrobble_history", "play_queue", + ], + }) + + # -- auth --------------------------------------------------------------- + + @bp.route("/auth/token", methods=["POST"]) + def issue_token(): + """Exchange account credentials for an API token (public endpoint).""" + data = _body() + username = (data.get("username") or "").strip() + password = data.get("password") or "" + user = None + if username: + from services.accounts import user_by_username + user = user_by_username(username) + if user is None or not verify_password(user, password): + return _error("invalid username or password", 401) + try: + plaintext, row = create_api_token( + user, label=data.get("label"), expires_in_days=data.get("expires_in_days")) + except ValueError as exc: + return _error(str(exc), 400) + return jsonify({ + "token": plaintext, + "token_type": "bearer", + "account": _account_json(user), + **token_json(row), + }), 201 + + @bp.route("/me", methods=["GET"]) + def me(): + user = _account() + if user is None: + return _error("authentication required", 401) + return jsonify({ + "account": _account_json(user), + "state": user_state.summary(user.id), + "library": library_query.stats(), + }) + + @bp.route("/tokens", methods=["GET", "POST"]) + def tokens(): + user = _account() + if user is None: + return _error("authentication required", 401) + if request.method == "GET": + return jsonify({"items": list_api_tokens(user)}) + data = _body() + try: + plaintext, row = create_api_token( + user, label=data.get("label"), expires_in_days=data.get("expires_in_days")) + except ValueError as exc: + return _error(str(exc), 400) + return jsonify({"token": plaintext, "token_type": "bearer", + **token_json(row)}), 201 + + @bp.route("/tokens/", methods=["DELETE"]) + def revoke_token(token_id): + user = _account() + if user is None: + return _error("authentication required", 401) + if not revoke_api_token(user, token_id): + return _error("no such token for this account", 404) + return jsonify({"ok": True}) + + # -- library ------------------------------------------------------------ + + @bp.route("/library/stats", methods=["GET"]) + def library_stats(): + if _account() is None: + return _error("authentication required", 401) + return jsonify(library_query.stats()) + + @bp.route("/library/artists", methods=["GET"]) + def library_artists(): + if _account() is None: + return _error("authentication required", 401) + return jsonify(library_query.artists( + q=request.args.get("q"), + offset=_int_arg("offset", 0), limit=_int_arg("limit", library_query.DEFAULT_PAGE), + sort=request.args.get("sort", "name"), order=request.args.get("order", "asc"))) + + @bp.route("/library/artists/", methods=["GET"]) + def library_artist(artist_id): + if _account() is None: + return _error("authentication required", 401) + artist = library_query.get_artist(artist_id) + if artist is None: + return _error(f"no artist with id {artist_id}", 404) + return jsonify(artist) + + @bp.route("/library/albums", methods=["GET"]) + def library_albums(): + if _account() is None: + return _error("authentication required", 401) + return jsonify(library_query.albums( + q=request.args.get("q"), artist_id=_int_arg("artist_id"), + offset=_int_arg("offset", 0), limit=_int_arg("limit", library_query.DEFAULT_PAGE), + sort=request.args.get("sort", "name"), order=request.args.get("order", "asc"))) + + @bp.route("/library/albums/", methods=["GET"]) + def library_album(album_id): + if _account() is None: + return _error("authentication required", 401) + album = library_query.get_album(album_id) + if album is None: + return _error(f"no album with id {album_id}", 404) + return jsonify(album) + + @bp.route("/library/tracks", methods=["GET"]) + def library_tracks(): + if _account() is None: + return _error("authentication required", 401) + downloaded = request.args.get("downloaded") + downloaded = None if downloaded is None else downloaded.lower() in ("1", "true", "yes") + return jsonify(library_query.tracks( + q=request.args.get("q"), album_id=_int_arg("album_id"), + artist_id=_int_arg("artist_id"), downloaded=downloaded, + status=request.args.get("status"), + offset=_int_arg("offset", 0), limit=_int_arg("limit", library_query.DEFAULT_PAGE), + sort=request.args.get("sort", "title"), order=request.args.get("order", "asc"))) + + @bp.route("/library/tracks/", methods=["GET"]) + def library_track(track_id): + if _account() is None: + return _error("authentication required", 401) + track = library_query.get_track(track_id) + if track is None: + return _error(f"no track with id {track_id}", 404) + return jsonify(track) + + # -- playlists ---------------------------------------------------------- + + @bp.route("/playlists", methods=["GET", "POST"]) + def playlists(): + user = _account() + if user is None: + return _error("authentication required", 401) + if request.method == "GET": + return jsonify(user_state.list_playlists( + user.id, offset=_int_arg("offset", 0), + limit=_int_arg("limit", user_state.DEFAULT_PAGE))) + data = _body() + return jsonify(user_state.create_playlist( + user.id, data.get("name"), comment=data.get("comment"), + track_ids=data.get("track_ids"))), 201 + + @bp.route("/playlists/", methods=["GET", "PATCH", "DELETE"]) + def playlist(playlist_id): + user = _account() + if user is None: + return _error("authentication required", 401) + if request.method == "GET": + return jsonify(user_state.get_playlist( + user.id, playlist_id, offset=_int_arg("offset", 0), + limit=_int_arg("limit"))) + if request.method == "DELETE": + user_state.delete_playlist(user.id, playlist_id) + return jsonify({"ok": True}) + data = _body() + kwargs = {} + if "name" in data: + kwargs["name"] = data["name"] + if "comment" in data: + kwargs["comment"] = data["comment"] + return jsonify(user_state.update_playlist(user.id, playlist_id, **kwargs)) + + @bp.route("/playlists//items", methods=["POST", "PUT"]) + def playlist_items(playlist_id): + user = _account() + if user is None: + return _error("authentication required", 401) + data = _body() + if request.method == "PUT": + # Full reorder: the exact item order for this playlist. + return jsonify(user_state.reorder_items(user.id, playlist_id, + data.get("item_ids"))) + return jsonify(user_state.add_tracks( + user.id, playlist_id, data.get("track_ids"), + position=data.get("position"))), 201 + + @bp.route("/playlists//items/", methods=["DELETE"]) + def playlist_item(playlist_id, item_id): + user = _account() + if user is None: + return _error("authentication required", 401) + return jsonify(user_state.remove_item(user.id, playlist_id, item_id)) + + # -- favorites ---------------------------------------------------------- + + @bp.route("/favorites", methods=["GET"]) + def favorites(): + user = _account() + if user is None: + return _error("authentication required", 401) + return jsonify(user_state.list_favorites( + user.id, item_type=request.args.get("type"), + offset=_int_arg("offset", 0), limit=_int_arg("limit", user_state.DEFAULT_PAGE))) + + @bp.route("/favorites//", methods=["GET", "PUT", "DELETE"]) + def favorite(item_type, item_id): + user = _account() + if user is None: + return _error("authentication required", 401) + if request.method == "GET": + return jsonify({"item_type": item_type, "item_id": item_id, + "starred": user_state.is_starred(user.id, item_type, item_id)}) + if request.method == "DELETE": + removed = user_state.unstar(user.id, item_type, item_id) + return jsonify({"ok": True, "removed": removed}) + return jsonify(user_state.star(user.id, item_type, item_id)) + + # -- ratings ------------------------------------------------------------ + + @bp.route("/ratings", methods=["GET"]) + def ratings(): + user = _account() + if user is None: + return _error("authentication required", 401) + return jsonify(user_state.list_ratings( + user.id, item_type=request.args.get("type"), + offset=_int_arg("offset", 0), limit=_int_arg("limit", user_state.DEFAULT_PAGE))) + + @bp.route("/ratings//", methods=["GET", "PUT", "DELETE"]) + def rating(item_type, item_id): + user = _account() + if user is None: + return _error("authentication required", 401) + if request.method == "GET": + return jsonify({"item_type": item_type, "item_id": item_id, + "rating": user_state.get_rating(user.id, item_type, item_id)}) + if request.method == "DELETE": + return jsonify(user_state.set_rating(user.id, item_type, item_id, None)) + return jsonify(user_state.set_rating(user.id, item_type, item_id, + _body().get("rating"))) + + # -- bookmarks ---------------------------------------------------------- + + @bp.route("/bookmarks", methods=["GET"]) + def bookmarks(): + user = _account() + if user is None: + return _error("authentication required", 401) + return jsonify(user_state.list_bookmarks( + user.id, offset=_int_arg("offset", 0), + limit=_int_arg("limit", user_state.DEFAULT_PAGE))) + + @bp.route("/bookmarks//", methods=["GET", "PUT", "DELETE"]) + def bookmark(item_type, item_id): + user = _account() + if user is None: + return _error("authentication required", 401) + if request.method == "GET": + bookmark_row = user_state.get_bookmark(user.id, item_type, item_id) + if bookmark_row is None: + return _error("no such bookmark", 404) + return jsonify(bookmark_row) + if request.method == "DELETE": + removed = user_state.delete_bookmark(user.id, item_type, item_id) + return jsonify({"ok": True, "removed": removed}) + data = _body() + return jsonify(user_state.set_bookmark(user.id, item_type, item_id, + data.get("position_ms"), + comment=data.get("comment"))) + + # -- scrobble history --------------------------------------------------- + + @bp.route("/scrobbles", methods=["GET", "POST"]) + def scrobbles(): + user = _account() + if user is None: + return _error("authentication required", 401) + if request.method == "GET": + return jsonify(user_state.list_scrobbles( + user.id, offset=_int_arg("offset", 0), + limit=_int_arg("limit", user_state.DEFAULT_PAGE), + since=request.args.get("since"))) + data = _body() + return jsonify(user_state.record_scrobble( + user.id, data.get("track_id"), played_at=data.get("played_at"), + submission=data.get("submission", True))), 201 + + @bp.route("/scrobbles/top", methods=["GET"]) + def scrobble_top(): + user = _account() + if user is None: + return _error("authentication required", 401) + return jsonify({"items": user_state.play_counts( + user.id, limit=_int_arg("limit", 50))}) + + # -- play queue --------------------------------------------------------- + + @bp.route("/queue", methods=["GET", "PUT", "DELETE"]) + def queue(): + user = _account() + if user is None: + return _error("authentication required", 401) + if request.method == "GET": + return jsonify(user_state.get_queue(user.id)) + if request.method == "DELETE": + return jsonify({"ok": True, "cleared": user_state.clear_queue(user.id)}) + data = _body() + return jsonify(user_state.save_queue( + user.id, data.get("track_ids"), current_index=data.get("current_index", 0), + position_ms=data.get("position_ms", 0), changed_by=data.get("changed_by"))) + + @bp.route("/state/summary", methods=["GET"]) + def state_summary(): + user = _account() + if user is None: + return _error("authentication required", 401) + return jsonify(user_state.summary(user.id)) + + return bp diff --git a/services/library_query.py b/services/library_query.py new file mode 100644 index 0000000..e61fc34 --- /dev/null +++ b/services/library_query.py @@ -0,0 +1,260 @@ +"""Provider-neutral library queries for integrations. + +A server/client integration needs to search and page through fnack's library +without loading it into memory and without knowing anything about metadata +providers. This module is that interface: SQL-side filtering, ordering and +pagination (``LIMIT``/``OFFSET`` + ``COUNT``), stable internal ids in the +results, and provider identities exposed only as opaque, separately scoped +values. + +Rules: + +* **Provider-neutral.** No provider implementation is imported or named; + ``provider_id``/``external_id`` are returned verbatim because core never + interprets them. +* **Stable ids.** Every result carries the internal ``Artist``/``Album``/ + ``Track`` id — the reference user state and integrations should store. +* **Bounded work.** Callers get a page + ``total``; ordering keys are + whitelisted so a caller cannot inject arbitrary SQL. +* Reads only: this module never mutates the library. +""" + +from __future__ import annotations + +from sqlalchemy import or_ + +from models import Album, Artist, Track, db + +DEFAULT_PAGE = 100 +MAX_PAGE = 500 + +_ARTIST_SORTS = {"name": Artist.name, "created": Artist.created_at, "id": Artist.id} +_ALBUM_SORTS = {"name": Album.name, "year": Album.year, "created": Album.created_at, + "id": Album.id} +_TRACK_SORTS = {"title": Track.title, "duration": Track.duration, + "track_number": Track.track_number, "created": Track.created_at, + "id": Track.id} + + +class LibraryQueryError(ValueError): + """Invalid query argument (bad sort key, bad page size, bad id).""" + + +def _page(offset, limit) -> tuple[int, int]: + try: + offset = max(0, int(offset or 0)) + except (TypeError, ValueError): + raise LibraryQueryError("offset must be an integer") + try: + limit = int(limit) if limit is not None else DEFAULT_PAGE + except (TypeError, ValueError): + raise LibraryQueryError("limit must be an integer") + if limit < 1: + raise LibraryQueryError("limit must be >= 1") + return offset, min(limit, MAX_PAGE) + + +def _sort_for(sorts: dict, sort, order): + if sort not in sorts: + raise LibraryQueryError( + f"unknown sort {sort!r} (expected one of {', '.join(sorted(sorts))})") + direction = (order or "asc").lower() + if direction not in ("asc", "desc"): + raise LibraryQueryError("order must be 'asc' or 'desc'") + column = sorts[sort] + return column.desc() if direction == "desc" else column.asc() + + +def _wrap(query, offset, limit, sorts, sort, order, serializer) -> dict: + offset, limit = _page(offset, limit) + total = query.count() + order_by = _sort_for(sorts, sort, order) + rows = query.order_by(order_by, sorts["id"].asc()).limit(limit).offset(offset).all() + return { + "items": [serializer(row) for row in rows], + "total": total, + "offset": offset, + "limit": limit, + "has_more": offset + len(rows) < total, + } + + +# --------------------------------------------------------------------------- +# serializers +# --------------------------------------------------------------------------- + +def artist_json(artist: Artist, album_count=None, track_count=None) -> dict: + out = { + "id": artist.id, + "name": artist.name, + "image_url": artist.image_url, + "monitored": bool(artist.monitored), + "created_at": _iso(artist.created_at), + # Opaque, provider-scoped identity: meaningful only to the provider + # that supplied it; never a primary reference. + "provider_id": artist.provider_id, + "external_id": artist.external_id, + } + if album_count is not None: + out["album_count"] = int(album_count) + if track_count is not None: + out["track_count"] = int(track_count) + return out + + +def album_json(album: Album, track_count=None, downloaded_count=None) -> dict: + out = { + "id": album.id, + "artist_id": album.artist_id, + "artist_name": getattr(album.artist, "name", None), + "name": album.name, + "year": album.year, + "cover_url": album.cover_url, + "record_type": album.record_type, + "is_downloaded": bool(album.is_downloaded), + "monitored": bool(album.monitored), + "created_at": _iso(album.created_at), + "provider_id": album.provider_id, + "external_id": album.external_id, + } + if track_count is not None: + out["track_count"] = int(track_count) + if downloaded_count is not None: + out["downloaded_count"] = int(downloaded_count) + return out + + +def track_json(track: Track) -> dict: + album = track.album + artist = None + if track.artist_id: + artist = db.session.get(Artist, track.artist_id) + if artist is None and album is not None: + artist = album.artist + return { + "id": track.id, + "title": track.title, + "album_id": track.album_id, + "album_name": getattr(album, "name", None), + "artist_id": getattr(artist, "id", None), + "artist_name": getattr(artist, "name", None), + "track_number": track.track_number, + "disc_number": track.disc_number, + "duration": track.duration, + "genre": track.genre, + "isrc": track.isrc, + "status": track.status, + "is_downloaded": bool(track.is_downloaded), + "is_unmatched": bool(track.is_unmatched), + "caution": bool(track.caution), + "bitrate": track.bitrate, + "file_format": track.file_format, + "size_bytes": track.size_bytes, + # Streaming metadata for a server integration (path on disk). + "stream_path": track.local_path or track.file_path or None, + "stream_ready": bool(track.is_downloaded and (track.local_path or track.file_path)), + "created_at": _iso(track.created_at), + "provider_id": track.provider_id, + "external_id": track.external_id, + } + + +# --------------------------------------------------------------------------- +# searches / listings +# --------------------------------------------------------------------------- + +def artists(q=None, offset=0, limit=DEFAULT_PAGE, sort="name", order="asc") -> dict: + """Search/list artists by name (case-insensitive substring).""" + query = Artist.query + if q: + query = query.filter(Artist.name.ilike(f"%{q}%")) + return _wrap(query, offset, limit, _ARTIST_SORTS, sort, order, artist_json) + + +def albums(q=None, artist_id=None, offset=0, limit=DEFAULT_PAGE, + sort="name", order="asc") -> dict: + """Search/list albums, optionally scoped to one artist.""" + query = Album.query + if artist_id is not None: + query = query.filter(Album.artist_id == _int_arg(artist_id, "artist_id")) + if q: + query = query.filter(Album.name.ilike(f"%{q}%")) + return _wrap(query, offset, limit, _ALBUM_SORTS, sort, order, album_json) + + +def tracks(q=None, album_id=None, artist_id=None, downloaded=None, status=None, + offset=0, limit=DEFAULT_PAGE, sort="title", order="asc") -> dict: + """Search/list tracks with optional album/artist/state filters. + + ``q`` matches the track title, its album name or its artist name so a + client can search generically without knowing which level it needs. + """ + query = Track.query + if album_id is not None: + query = query.filter(Track.album_id == _int_arg(album_id, "album_id")) + if artist_id is not None: + query = query.filter(Track.artist_id == _int_arg(artist_id, "artist_id")) + if downloaded is not None: + query = query.filter(Track.is_downloaded.is_(bool(downloaded))) + if status: + query = query.filter(Track.status == str(status)) + if q: + like = f"%{q}%" + query = (query.outerjoin(Album, Track.album_id == Album.id) + .outerjoin(Artist, Track.artist_id == Artist.id) + .filter(or_(Track.title.ilike(like), Album.name.ilike(like), + Artist.name.ilike(like)))) + return _wrap(query, offset, limit, _TRACK_SORTS, sort, order, track_json) + + +# --------------------------------------------------------------------------- +# direct lookups +# --------------------------------------------------------------------------- + +def get_artist(artist_id) -> dict | None: + """One artist with its album/track counts (single round of queries).""" + artist = db.session.get(Artist, _int_arg(artist_id, "artist_id")) + if artist is None: + return None + album_count = Album.query.filter_by(artist_id=artist.id).count() + track_count = Track.query.filter_by(artist_id=artist.id).count() + return artist_json(artist, album_count=album_count, track_count=track_count) + + +def get_album(album_id) -> dict | None: + """One album with its track and downloaded counts.""" + album = db.session.get(Album, _int_arg(album_id, "album_id")) + if album is None: + return None + track_count = Track.query.filter_by(album_id=album.id).count() + downloaded_count = Track.query.filter_by(album_id=album.id, + is_downloaded=True).count() + return album_json(album, track_count=track_count, downloaded_count=downloaded_count) + + +def get_track(track_id) -> dict | None: + """One track by its stable internal id.""" + track = db.session.get(Track, _int_arg(track_id, "track_id")) + return track_json(track) if track is not None else None + + +def stats() -> dict: + """Library counts + how much of it is downloaded (cheap, indexed).""" + return { + "artists": Artist.query.count(), + "albums": Album.query.count(), + "tracks": Track.query.count(), + "downloaded_tracks": Track.query.filter_by(is_downloaded=True).count(), + "albums_downloaded": Album.query.filter_by(is_downloaded=True).count(), + } + + +def _int_arg(value, name) -> int: + try: + return int(value) + except (TypeError, ValueError): + raise LibraryQueryError(f"{name} must be an integer, got {value!r}") + + +def _iso(value): + return value.isoformat() if value is not None else None diff --git a/services/schema_migrations.py b/services/schema_migrations.py index c2b9969..96ec306 100644 --- a/services/schema_migrations.py +++ b/services/schema_migrations.py @@ -193,6 +193,25 @@ def run_schema_migrations(engine=None) -> None: except Exception: logger.exception("[SCHEMA] artists per-provider rebuild failed") + # Per-account user state + API tokens (playlists, playlist items, + # favorites, ratings, bookmarks, scrobble history, saved queue, + # account tokens). Additive and idempotent: create_all with + # checkfirst only creates what is missing, so an existing + # installation keeps every library row and every account. + try: + from models import ( # noqa: PLC0415 - migration-local import + ApiToken, Bookmark, Favorite, Playlist, PlaylistItem, PlayQueue, + PlayQueueEntry, Rating, Scrobble, + ) + _user_state_tables = [ + ApiToken.__table__, Playlist.__table__, PlaylistItem.__table__, + Favorite.__table__, Rating.__table__, Bookmark.__table__, + Scrobble.__table__, PlayQueue.__table__, PlayQueueEntry.__table__, + ] + db.metadata.create_all(bind=conn, tables=_user_state_tables, checkfirst=True) + except Exception: + logger.exception("[SCHEMA] user-state tables migration failed") + # Indexes for the provider-scoped identity model (recreated after # the artists rebuild dropped the old ones; idempotent). for ddl in [ diff --git a/services/user_state.py b/services/user_state.py new file mode 100644 index 0000000..83a3423 --- /dev/null +++ b/services/user_state.py @@ -0,0 +1,1009 @@ +"""Generic per-account user state: playlists, favorites, ratings, bookmarks, +scrobble history and the saved play queue. + +Design rules (see docs/integration-api.md): + +* **Account-scoped.** Every function takes ``user_id`` first and every query + filters by it. A row that belongs to another account is reported as + ``NotFound`` — existence is never leaked across accounts. +* **Stable references.** State stores fnack's INTERNAL ``Artist``/``Album``/ + ``Track`` ids. Provider identities are never used as primary references and + are never required to resolve a saved item. +* **Outlives the library.** The id columns carry no foreign-key constraint, so + deleting a track (a discography sync prune, an explicit artist delete) can + never cascade away or block user state. Each row keeps a small snapshot + (title/artist/album/duration/isrc) so it stays readable while the referenced + object is missing, and ``relink_tracks()`` re-attaches state to a re-created + object through its ISRC (the provider-neutral recording identifier). +* **Validated.** Every referenced library id is checked to exist before it is + persisted; unknown ids raise ``ValidationError``. +* **Deterministic ordering.** Playlist and queue positions are unique per + owner and compacted to ``0..n-1`` after every mutation, so ordering never + depends on insertion history. + +This module is provider-neutral: it imports no provider implementation and +knows nothing about any client protocol. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import case +from sqlalchemy.orm import joinedload + +from models import ( + Album, + Artist, + Bookmark, + Favorite, + Playlist, + PlaylistItem, + PlayQueue, + PlayQueueEntry, + Rating, + Scrobble, + Track, + User, + db, +) + +ITEM_TYPES = ("artist", "album", "track") +BOOKMARK_TYPES = ("album", "track") +MAX_RATING = 5 +DEFAULT_PAGE = 100 +MAX_PAGE = 500 + +# Positions are moved to this offset during two-phase reordering so the +# per-owner uniqueness constraint is never transiently violated. +_POSITION_SHIFT = 1_000_000 + +_UNSET = object() + + +class UserStateError(Exception): + """Base class for user-state domain errors.""" + + +class NotFound(UserStateError): + """The object does not exist, or is not owned by this account.""" + + +class ValidationError(UserStateError): + """The request is malformed or references unknown library objects.""" + + +# --------------------------------------------------------------------------- +# validation + snapshot helpers +# --------------------------------------------------------------------------- + +def _model_for(item_type: str): + mapping = {"artist": Artist, "album": Album, "track": Track} + try: + return mapping[item_type] + except KeyError: + raise ValidationError( + f"unknown item_type {item_type!r} (expected one of {', '.join(ITEM_TYPES)})") + + +def _require_account(user_id) -> int: + if not user_id: + raise ValidationError("an account is required") + if db.session.get(User, user_id) is None: + raise NotFound(f"no such account: {user_id}") + return int(user_id) + + +def require_library_object(item_type: str, item_id) -> object: + """Validate that a library object of ``item_type`` with ``item_id`` exists. + + Raises ValidationError (unknown id) — used before persisting any reference. + """ + model = _model_for(item_type) + try: + item_id = int(item_id) + except (TypeError, ValueError): + raise ValidationError(f"item_id must be an integer, got {item_id!r}") + obj = db.session.get(model, item_id) + if obj is None: + raise ValidationError(f"no {item_type} with id {item_id}") + return obj + + +def _artist_of(track: Track): + """The track's artist: its own artist_id, else the album's artist.""" + if getattr(track, "artist_id", None): + artist = db.session.get(Artist, track.artist_id) + if artist is not None: + return artist + album = track.album + return album.artist if album is not None else None + + +def _track_snapshot(track: Track) -> dict: + album = track.album + artist = _artist_of(track) + return { + "title": track.title, + "artist_name": getattr(artist, "name", None), + "album_name": getattr(album, "name", None), + "duration": track.duration, + "isrc": track.isrc, + } + + +def _snapshot_for(item_type: str, obj) -> dict: + """Snapshot fields for any supported library object type.""" + if item_type == "track": + return _track_snapshot(obj) + return {"title": getattr(obj, "name", None), "artist_name": None, + "album_name": None, "duration": None, "isrc": None} + + +def _page(offset, limit) -> tuple[int, int]: + try: + offset = max(0, int(offset or 0)) + except (TypeError, ValueError): + offset = 0 + try: + limit = int(limit) if limit is not None else DEFAULT_PAGE + except (TypeError, ValueError): + limit = DEFAULT_PAGE + limit = max(1, min(limit, MAX_PAGE)) + return offset, limit + + +def _live_tracks(track_ids) -> dict: + """{track_id: json dict} for ids that still exist in the library.""" + ids = [int(i) for i in {t for t in track_ids if t is not None}] + if not ids: + return {} + rows = (Track.query + .options(joinedload(Track.album).joinedload(Album.artist)) + .filter(Track.id.in_(ids)).all()) + out = {} + for t in rows: + album = t.album + artist = _artist_of(t) + out[t.id] = { + "id": t.id, + "title": t.title, + "album_id": t.album_id, + "album_name": getattr(album, "name", None), + "artist_id": getattr(artist, "id", None), + "artist_name": getattr(artist, "name", None), + "duration": t.duration, + "track_number": t.track_number, + "disc_number": t.disc_number, + "isrc": t.isrc, + "is_downloaded": bool(t.is_downloaded), + } + return out + + +def _resolve_entry(row, live: dict) -> dict: + """A state entry with its live track merged in (or a snapshot when gone).""" + entry = { + "track_id": row.track_id, + "title": row.title, + "artist_name": row.artist_name, + "album_name": row.album_name, + "duration": row.duration, + "isrc": row.isrc, + "available": False, + } + if row.track_id is not None and row.track_id in live: + track = dict(live[row.track_id]) + entry.update(track) + entry["available"] = True + # Live library metadata wins; snapshots are only the fallback. + if track.get("title"): + entry["title"] = track["title"] + if track.get("artist_name"): + entry["artist_name"] = track["artist_name"] + if track.get("album_name"): + entry["album_name"] = track["album_name"] + return entry + + +# --------------------------------------------------------------------------- +# playlists +# --------------------------------------------------------------------------- + +def _playlist_for(user_id, playlist_id) -> Playlist: + try: + playlist_id = int(playlist_id) + except (TypeError, ValueError): + raise ValidationError(f"playlist_id must be an integer, got {playlist_id!r}") + playlist = Playlist.query.filter_by(id=playlist_id, user_id=int(user_id)).first() + if playlist is None: + # Same error whether it does not exist or belongs to someone else. + raise NotFound(f"no playlist {playlist_id} for this account") + return playlist + + +def _ordered_items(playlist: Playlist) -> list: + return sorted(playlist.items, key=lambda i: (i.position, i.id)) + + +def _assign_positions(playlist_id: int, ordered_ids: list) -> None: + """Write 0..n-1 positions for ``ordered_ids`` in ONE statement. + + Uses a CASE expression rather than per-object assignment on purpose: the + bulk shift below runs outside the ORM, so in-session objects still hold + their pre-shift values — assigning through them would look like a no-op + for any row whose new position happens to equal its stale value and the + UPDATE would silently be skipped. + """ + if not ordered_ids: + return + mapping = {int(item_id): position for position, item_id in enumerate(ordered_ids)} + db.session.query(PlaylistItem).filter( + PlaylistItem.playlist_id == playlist_id, + PlaylistItem.id.in_(list(mapping)), + ).update({PlaylistItem.position: case(mapping, value=PlaylistItem.id)}, + synchronize_session=False) + db.session.flush() + db.session.expire_all() + + +def _write_positions(playlist: Playlist, ordered_ids: list) -> None: + """Assign 0..n-1 positions following ``ordered_ids`` without ever leaving + two rows on the same position (two-phase move through _POSITION_SHIFT).""" + items = {item.id: item for item in playlist.items} + unknown = [i for i in ordered_ids if i not in items] + if unknown: + raise ValidationError(f"unknown playlist item ids: {unknown}") + db.session.query(PlaylistItem).filter_by(playlist_id=playlist.id).update( + {PlaylistItem.position: PlaylistItem.position + _POSITION_SHIFT}, + synchronize_session=False, + ) + db.session.flush() + _assign_positions(playlist.id, ordered_ids) + + +def _insert_items(playlist: Playlist, track_ids, position=None) -> list: + """Insert tracks at ``position`` (or append) with deterministic order. + + Three phases so the per-playlist uniqueness constraint is never violated, + not even transiently by an autoflush: existing rows move into the safe + high range, new rows are inserted above them, then every row is assigned + its final 0..n-1 position in one pass. + """ + tracks = [] + for track_id in track_ids: + try: + track_id = int(track_id) + except (TypeError, ValueError): + raise ValidationError(f"track_id must be an integer, got {track_id!r}") + track = db.session.get(Track, track_id) + if track is None: + raise ValidationError(f"no track with id {track_id}") + tracks.append(track) + + ordered = _ordered_items(playlist) + if position is None: + insert_at = len(ordered) + else: + try: + insert_at = int(position) + except (TypeError, ValueError): + raise ValidationError(f"position must be an integer, got {position!r}") + insert_at = max(0, min(insert_at, len(ordered))) + + # 1) existing rows out of the way + db.session.query(PlaylistItem).filter_by(playlist_id=playlist.id).update( + {PlaylistItem.position: PlaylistItem.position + _POSITION_SHIFT}, + synchronize_session=False) + db.session.flush() + + # 2) new rows above everything currently stored + new_rows = [] + for offset, track in enumerate(tracks): + row = PlaylistItem(playlist_id=playlist.id, + position=_POSITION_SHIFT * 2 + offset, + track_id=track.id, **_track_snapshot(track)) + db.session.add(row) + new_rows.append(row) + db.session.flush() + + # 3) final compact order (single CASE statement — see _assign_positions) + final_order = ([item.id for item in ordered[:insert_at]] + + [row.id for row in new_rows] + + [item.id for item in ordered[insert_at:]]) + _assign_positions(playlist.id, final_order) + return new_rows + + +def create_playlist(user_id, name, comment=None, track_ids=None) -> dict: + """Create a playlist owned by ``user_id`` (optionally pre-filled).""" + user_id = _require_account(user_id) + name = (name or "").strip() + if not name: + raise ValidationError("playlist name is required") + if len(name) > 256: + raise ValidationError("playlist name is too long (max 256 characters)") + playlist = Playlist(user_id=user_id, name=name, comment=comment) + db.session.add(playlist) + db.session.flush() + if track_ids: + _insert_items(playlist, track_ids) + db.session.commit() + return get_playlist(user_id, playlist.id) + + +def list_playlists(user_id, offset=0, limit=DEFAULT_PAGE) -> dict: + """Playlists owned by this account (metadata + item counts).""" + user_id = _require_account(user_id) + offset, limit = _page(offset, limit) + query = Playlist.query.filter_by(user_id=user_id) + total = query.count() + rows = (query.order_by(Playlist.name.asc(), Playlist.id.asc()) + .limit(limit).offset(offset).all()) + counts = dict( + db.session.query(PlaylistItem.playlist_id, db.func.count(PlaylistItem.id)) + .filter(PlaylistItem.playlist_id.in_([p.id for p in rows] or [0])) + .group_by(PlaylistItem.playlist_id).all() + ) if rows else {} + return { + "items": [{ + "id": p.id, + "name": p.name, + "comment": p.comment, + "item_count": int(counts.get(p.id, 0)), + "created_at": _iso(p.created_at), + "updated_at": _iso(p.updated_at), + } for p in rows], + "total": total, + "offset": offset, + "limit": limit, + } + + +def get_playlist(user_id, playlist_id, offset=0, limit=None) -> dict: + """One playlist with its items resolved against the live library.""" + user_id = _require_account(user_id) + playlist = _playlist_for(user_id, playlist_id) + items = _ordered_items(playlist) + total = len(items) + if limit is not None: + offset, limit = _page(offset, limit) + page_items = items[offset:offset + limit] + else: + offset, page_items = 0, items + live = _live_tracks([i.track_id for i in page_items]) + return { + "id": playlist.id, + "name": playlist.name, + "comment": playlist.comment, + "created_at": _iso(playlist.created_at), + "updated_at": _iso(playlist.updated_at), + "total": total, + "offset": offset, + "items": [dict(_resolve_entry(i, live), item_id=i.id, position=i.position) + for i in page_items], + } + + +def update_playlist(user_id, playlist_id, name=_UNSET, comment=_UNSET) -> dict: + """Rename/annotate a playlist. Unset arguments are left unchanged.""" + user_id = _require_account(user_id) + playlist = _playlist_for(user_id, playlist_id) + if name is not _UNSET: + name = (name or "").strip() + if not name: + raise ValidationError("playlist name is required") + if len(name) > 256: + raise ValidationError("playlist name is too long (max 256 characters)") + playlist.name = name + if comment is not _UNSET: + playlist.comment = comment + db.session.commit() + return get_playlist(user_id, playlist.id) + + +def delete_playlist(user_id, playlist_id) -> bool: + user_id = _require_account(user_id) + playlist = _playlist_for(user_id, playlist_id) + db.session.delete(playlist) + db.session.commit() + return True + + +def add_tracks(user_id, playlist_id, track_ids, position=None) -> dict: + """Append (or insert at ``position``) tracks; ordering stays compact.""" + user_id = _require_account(user_id) + playlist = _playlist_for(user_id, playlist_id) + if not track_ids: + raise ValidationError("track_ids is required") + _insert_items(playlist, track_ids, position=position) + db.session.commit() + return get_playlist(user_id, playlist.id) + + +def remove_item(user_id, playlist_id, item_id) -> dict: + """Remove one entry and re-compact the remaining positions.""" + user_id = _require_account(user_id) + playlist = _playlist_for(user_id, playlist_id) + try: + item_id = int(item_id) + except (TypeError, ValueError): + raise ValidationError(f"item_id must be an integer, got {item_id!r}") + item = PlaylistItem.query.filter_by(id=item_id, playlist_id=playlist.id).first() + if item is None: + raise NotFound(f"no playlist item {item_id} in playlist {playlist.id}") + db.session.delete(item) + db.session.flush() + remaining = [i.id for i in _ordered_items(playlist) if i.id != item_id] + _write_positions(playlist, remaining) + db.session.commit() + return get_playlist(user_id, playlist.id) + + +def reorder_items(user_id, playlist_id, item_ids) -> dict: + """Set the full item order. + + ``item_ids`` must be exactly the playlist's item ids (no duplicates, no + extras) — a partial or ambiguous order is refused instead of guessed. + """ + user_id = _require_account(user_id) + playlist = _playlist_for(user_id, playlist_id) + ids = [int(i) for i in (item_ids or [])] + existing = {i.id for i in playlist.items} + if len(ids) != len(set(ids)): + raise ValidationError("item_ids contains duplicates") + if set(ids) != existing: + raise ValidationError( + "item_ids must contain exactly the playlist's item ids " + f"(missing: {sorted(existing - set(ids))}, unknown: {sorted(set(ids) - existing)})") + _write_positions(playlist, ids) + db.session.commit() + return get_playlist(user_id, playlist.id) + + +def set_items(user_id, playlist_id, track_ids) -> dict: + """Replace a playlist's contents with ``track_ids`` in the given order.""" + user_id = _require_account(user_id) + playlist = _playlist_for(user_id, playlist_id) + for item in list(playlist.items): + db.session.delete(item) + db.session.flush() + db.session.expire(playlist, ["items"]) + if track_ids: + _insert_items(playlist, track_ids) + db.session.commit() + return get_playlist(user_id, playlist.id) + + +# --------------------------------------------------------------------------- +# favorites (stars) +# --------------------------------------------------------------------------- + +def star(user_id, item_type, item_id) -> dict: + """Star a library object (idempotent).""" + user_id = _require_account(user_id) + obj = require_library_object(item_type, item_id) + snapshot = _snapshot_for(item_type, obj) + row = Favorite.query.filter_by(user_id=user_id, item_type=item_type, + item_id=int(item_id)).first() + if row is None: + row = Favorite(user_id=user_id, item_type=item_type, item_id=int(item_id)) + db.session.add(row) + row.name = snapshot["title"] + row.isrc = snapshot["isrc"] + db.session.commit() + return _favorite_json(row) + + +def unstar(user_id, item_type, item_id) -> bool: + user_id = _require_account(user_id) + _model_for(item_type) + row = Favorite.query.filter_by(user_id=user_id, item_type=item_type, + item_id=int(item_id)).first() + if row is None: + return False + db.session.delete(row) + db.session.commit() + return True + + +def is_starred(user_id, item_type, item_id) -> bool: + user_id = _require_account(user_id) + _model_for(item_type) + return Favorite.query.filter_by(user_id=user_id, item_type=item_type, + item_id=int(item_id)).first() is not None + + +def list_favorites(user_id, item_type=None, offset=0, limit=DEFAULT_PAGE) -> dict: + user_id = _require_account(user_id) + offset, limit = _page(offset, limit) + query = Favorite.query.filter_by(user_id=user_id) + if item_type: + _model_for(item_type) + query = query.filter_by(item_type=item_type) + total = query.count() + rows = (query.order_by(Favorite.created_at.desc(), Favorite.id.desc()) + .limit(limit).offset(offset).all()) + live = _live_tracks([r.item_id for r in rows if r.item_type == "track"]) + return { + "items": [_favorite_json(r, live) for r in rows], + "total": total, + "offset": offset, + "limit": limit, + } + + +def _favorite_json(row: Favorite, live=None) -> dict: + live = live or {} + available = _object_exists(row.item_type, row.item_id) + out = { + "item_type": row.item_type, + "item_id": row.item_id, + "name": row.name, + "isrc": row.isrc, + "created_at": _iso(row.created_at), + "available": available, + } + if row.item_type == "track" and row.item_id in live: + out["track"] = live[row.item_id] + return out + + +def _object_exists(item_type: str, item_id) -> bool: + model = _model_for(item_type) + return db.session.get(model, item_id) is not None + + +# --------------------------------------------------------------------------- +# ratings +# --------------------------------------------------------------------------- + +def set_rating(user_id, item_type, item_id, rating) -> dict: + """Set (1..5) or clear (None/0) a rating for a library object.""" + user_id = _require_account(user_id) + obj = require_library_object(item_type, item_id) + row = Rating.query.filter_by(user_id=user_id, item_type=item_type, + item_id=int(item_id)).first() + if rating in (None, 0, "0", ""): + if row is not None: + db.session.delete(row) + db.session.commit() + return {"item_type": item_type, "item_id": int(item_id), "rating": None} + try: + rating = int(rating) + except (TypeError, ValueError): + raise ValidationError(f"rating must be an integer 1..{MAX_RATING}, got {rating!r}") + if not 1 <= rating <= MAX_RATING: + raise ValidationError(f"rating must be between 1 and {MAX_RATING}") + if row is None: + row = Rating(user_id=user_id, item_type=item_type, item_id=int(item_id)) + db.session.add(row) + row.rating = rating + if item_type == "track": + row.isrc = obj.isrc + db.session.commit() + return {"item_type": item_type, "item_id": int(item_id), "rating": rating, + "updated_at": _iso(row.updated_at)} + + +def get_rating(user_id, item_type, item_id): + user_id = _require_account(user_id) + _model_for(item_type) + row = Rating.query.filter_by(user_id=user_id, item_type=item_type, + item_id=int(item_id)).first() + return row.rating if row else None + + +def list_ratings(user_id, item_type=None, offset=0, limit=DEFAULT_PAGE) -> dict: + user_id = _require_account(user_id) + offset, limit = _page(offset, limit) + query = Rating.query.filter_by(user_id=user_id) + if item_type: + _model_for(item_type) + query = query.filter_by(item_type=item_type) + total = query.count() + rows = (query.order_by(Rating.updated_at.desc(), Rating.id.desc()) + .limit(limit).offset(offset).all()) + return { + "items": [{"item_type": r.item_type, "item_id": r.item_id, + "rating": r.rating, "available": _object_exists(r.item_type, r.item_id), + "updated_at": _iso(r.updated_at)} for r in rows], + "total": total, + "offset": offset, + "limit": limit, + } + + +# --------------------------------------------------------------------------- +# bookmarks +# --------------------------------------------------------------------------- + +def set_bookmark(user_id, item_type, item_id, position_ms, comment=None) -> dict: + """Create/update a playback position bookmark for this account.""" + user_id = _require_account(user_id) + if item_type not in BOOKMARK_TYPES: + raise ValidationError( + f"bookmarks support {', '.join(BOOKMARK_TYPES)} (got {item_type!r})") + obj = require_library_object(item_type, item_id) + try: + position_ms = int(position_ms) + except (TypeError, ValueError): + raise ValidationError("position_ms must be an integer") + if position_ms < 0: + raise ValidationError("position_ms must be >= 0") + row = Bookmark.query.filter_by(user_id=user_id, item_type=item_type, + item_id=int(item_id)).first() + if row is None: + row = Bookmark(user_id=user_id, item_type=item_type, item_id=int(item_id)) + db.session.add(row) + row.position_ms = position_ms + row.comment = comment + row.title = getattr(obj, "title", None) or getattr(obj, "name", None) + if item_type == "track": + row.isrc = obj.isrc + db.session.commit() + return _bookmark_json(row) + + +def get_bookmark(user_id, item_type, item_id): + user_id = _require_account(user_id) + if item_type not in BOOKMARK_TYPES: + raise ValidationError(f"bookmarks support {', '.join(BOOKMARK_TYPES)}") + row = Bookmark.query.filter_by(user_id=user_id, item_type=item_type, + item_id=int(item_id)).first() + return _bookmark_json(row) if row else None + + +def delete_bookmark(user_id, item_type, item_id) -> bool: + user_id = _require_account(user_id) + if item_type not in BOOKMARK_TYPES: + raise ValidationError(f"bookmarks support {', '.join(BOOKMARK_TYPES)}") + row = Bookmark.query.filter_by(user_id=user_id, item_type=item_type, + item_id=int(item_id)).first() + if row is None: + return False + db.session.delete(row) + db.session.commit() + return True + + +def list_bookmarks(user_id, offset=0, limit=DEFAULT_PAGE) -> dict: + user_id = _require_account(user_id) + offset, limit = _page(offset, limit) + query = Bookmark.query.filter_by(user_id=user_id) + total = query.count() + rows = (query.order_by(Bookmark.updated_at.desc(), Bookmark.id.desc()) + .limit(limit).offset(offset).all()) + return { + "items": [_bookmark_json(r) for r in rows], + "total": total, + "offset": offset, + "limit": limit, + } + + +def _bookmark_json(row: Bookmark) -> dict: + live = _live_tracks([row.item_id]) if row.item_type == "track" else {} + return { + "item_type": row.item_type, + "item_id": row.item_id, + "position_ms": row.position_ms, + "comment": row.comment, + "title": row.title, + "isrc": row.isrc, + "created_at": _iso(row.created_at), + "updated_at": _iso(row.updated_at), + "available": bool(live) or _object_exists(row.item_type, row.item_id), + } + + +# --------------------------------------------------------------------------- +# scrobble / play history +# --------------------------------------------------------------------------- + +def record_scrobble(user_id, track_id, played_at=None, submission=True) -> dict: + """Record a playback event for this account.""" + user_id = _require_account(user_id) + track = require_library_object("track", track_id) + snapshot = _track_snapshot(track) + played_at = played_at or datetime.now(timezone.utc) + if isinstance(played_at, str): + played_at = _parse_datetime(played_at) + row = Scrobble(user_id=user_id, track_id=track.id, played_at=played_at, + submission=bool(submission), **snapshot) + db.session.add(row) + db.session.commit() + return _scrobble_json(row) + + +def list_scrobbles(user_id, offset=0, limit=DEFAULT_PAGE, since=None) -> dict: + """Play history, newest first (``since`` = ISO-8601 timestamp).""" + user_id = _require_account(user_id) + offset, limit = _page(offset, limit) + query = Scrobble.query.filter_by(user_id=user_id) + if since: + query = query.filter(Scrobble.played_at >= _parse_datetime(since)) + total = query.count() + rows = (query.order_by(Scrobble.played_at.desc(), Scrobble.id.desc()) + .limit(limit).offset(offset).all()) + return { + "items": [_scrobble_json(r) for r in rows], + "total": total, + "offset": offset, + "limit": limit, + } + + +def play_counts(user_id, limit=50) -> list: + """Most-played tracks for this account: [{track_id, plays, title, ...}].""" + user_id = _require_account(user_id) + limit = max(1, min(int(limit or 50), MAX_PAGE)) + rows = (db.session.query(Scrobble.track_id, db.func.count(Scrobble.id).label("plays")) + .filter(Scrobble.user_id == user_id, Scrobble.track_id.isnot(None)) + .group_by(Scrobble.track_id) + .order_by(db.text("plays DESC")) + .limit(limit).all()) + live = _live_tracks([r[0] for r in rows]) + out = [] + for track_id, plays in rows: + entry = {"track_id": track_id, "plays": int(plays)} + if track_id in live: + entry.update(live[track_id]) + entry["available"] = True + else: + entry["available"] = False + out.append(entry) + return out + + +def _scrobble_json(row: Scrobble) -> dict: + return { + "id": row.id, + "track_id": row.track_id, + "title": row.title, + "artist_name": row.artist_name, + "album_name": row.album_name, + "isrc": row.isrc, + "played_at": _iso(row.played_at), + "submission": bool(row.submission), + "available": _object_exists("track", row.track_id) if row.track_id else False, + } + + +# --------------------------------------------------------------------------- +# saved play queue +# --------------------------------------------------------------------------- + +def save_queue(user_id, track_ids, current_index=0, position_ms=0, changed_by=None) -> dict: + """Replace this account's saved queue.""" + user_id = _require_account(user_id) + ids = [] + for track_id in (track_ids or []): + try: + track_id = int(track_id) + except (TypeError, ValueError): + raise ValidationError(f"track_id must be an integer, got {track_id!r}") + if db.session.get(Track, track_id) is None: + raise ValidationError(f"no track with id {track_id}") + ids.append(track_id) + try: + current_index = int(current_index or 0) + except (TypeError, ValueError): + raise ValidationError("current_index must be an integer") + try: + position_ms = int(position_ms or 0) + except (TypeError, ValueError): + raise ValidationError("position_ms must be an integer") + if ids: + current_index = max(0, min(current_index, len(ids) - 1)) + else: + current_index = 0 + + queue = PlayQueue.query.filter_by(user_id=user_id).first() + if queue is None: + queue = PlayQueue(user_id=user_id) + db.session.add(queue) + db.session.flush() + # Clear entries before re-inserting so the unique position constraint can + # never see a duplicate during the swap. + db.session.query(PlayQueueEntry).filter_by(queue_id=queue.id).delete( + synchronize_session=False) + db.session.flush() + for position, track_id in enumerate(ids): + track = db.session.get(Track, track_id) + db.session.add(PlayQueueEntry( + user_id=user_id, queue_id=queue.id, position=position, + track_id=track_id, **_track_snapshot(track))) + queue.current_index = current_index + queue.position_ms = position_ms + queue.changed_by = changed_by + queue.changed_at = datetime.now(timezone.utc) + db.session.commit() + return get_queue(user_id) + + +def get_queue(user_id) -> dict: + """This account's saved queue, entries resolved against the library.""" + user_id = _require_account(user_id) + queue = PlayQueue.query.filter_by(user_id=user_id).first() + if queue is None: + return {"current_index": 0, "position_ms": 0, "changed_by": None, + "changed_at": None, "total": 0, "items": []} + rows = sorted(queue.entries, key=lambda e: (e.position, e.id)) + live = _live_tracks([e.track_id for e in rows]) + return { + "current_index": queue.current_index, + "position_ms": queue.position_ms, + "changed_by": queue.changed_by, + "changed_at": _iso(queue.changed_at), + "updated_at": _iso(queue.updated_at), + "total": len(rows), + "items": [dict(_resolve_entry(r, live), position=r.position, entry_id=r.id) + for r in rows], + } + + +def clear_queue(user_id) -> bool: + user_id = _require_account(user_id) + queue = PlayQueue.query.filter_by(user_id=user_id).first() + if queue is None: + return False + db.session.query(PlayQueueEntry).filter_by(queue_id=queue.id).delete( + synchronize_session=False) + queue.current_index = 0 + queue.position_ms = 0 + queue.changed_at = datetime.now(timezone.utc) + db.session.commit() + return True + + +# --------------------------------------------------------------------------- +# library churn: keep state alive and re-attachable +# --------------------------------------------------------------------------- + +def snapshot_referenced_tracks(track_ids) -> int: + """Refresh snapshots for state that references tracks about to disappear. + + Call this BEFORE hard-deleting library rows (a discography sync prune, an + artist delete). The state rows are never removed: they keep their internal + id plus a readable snapshot, and can be re-attached later by ISRC. + """ + ids = [int(i) for i in (track_ids or [])] + if not ids: + return 0 + tracks = {t.id: t for t in Track.query.filter(Track.id.in_(ids)).all()} + if not tracks: + return 0 + touched = 0 + for row in PlaylistItem.query.filter(PlaylistItem.track_id.in_(ids)).all(): + _apply_snapshot(row, tracks.get(row.track_id)) + touched += 1 + for row in PlayQueueEntry.query.filter(PlayQueueEntry.track_id.in_(ids)).all(): + _apply_snapshot(row, tracks.get(row.track_id)) + touched += 1 + for fav in Favorite.query.filter(Favorite.item_type == "track", + Favorite.item_id.in_(ids)).all(): + track = tracks.get(fav.item_id) + if track: + fav.name = track.title + fav.isrc = track.isrc + touched += 1 + for rat in Rating.query.filter(Rating.item_type == "track", + Rating.item_id.in_(ids)).all(): + track = tracks.get(rat.item_id) + if track: + rat.isrc = track.isrc + touched += 1 + for bm in Bookmark.query.filter(Bookmark.item_type == "track", + Bookmark.item_id.in_(ids)).all(): + track = tracks.get(bm.item_id) + if track: + bm.title = track.title + bm.isrc = track.isrc + touched += 1 + db.session.flush() + return touched + + +def _apply_snapshot(row, track) -> None: + if track is None: + return + snapshot = _track_snapshot(track) + row.title = snapshot["title"] + row.artist_name = snapshot["artist_name"] + row.album_name = snapshot["album_name"] + row.duration = snapshot["duration"] + row.isrc = snapshot["isrc"] + + +def relink_tracks(track_ids) -> int: + """Re-attach state to freshly created library tracks via ISRC. + + A provider change can delete and re-create the same recording under a new + internal id. State that references the old id (and whose old id no longer + resolves) is re-pointed at the new row when the ISRC matches — so a star, + rating, bookmark, playlist entry or queued item survives the churn. + """ + ids = [int(i) for i in (track_ids or [])] + if not ids: + return 0 + tracks = Track.query.filter(Track.id.in_(ids)).all() + relinked = 0 + for track in tracks: + if not track.isrc: + continue + candidate = {"title": track.title, "artist_name": None, + "album_name": None, "duration": track.duration, + "isrc": track.isrc} + candidate["artist_name"] = getattr(_artist_of(track), "name", None) + candidate["album_name"] = getattr(track.album, "name", None) + stale = lambda tid: tid is not None and db.session.get(Track, tid) is None # noqa: E731 + + for row in PlaylistItem.query.filter_by(isrc=track.isrc).all(): + if stale(row.track_id): + row.track_id = track.id + _apply_snapshot(row, track) + relinked += 1 + for row in PlayQueueEntry.query.filter_by(isrc=track.isrc).all(): + if stale(row.track_id): + row.track_id = track.id + _apply_snapshot(row, track) + relinked += 1 + for fav in Favorite.query.filter_by(item_type="track", isrc=track.isrc).all(): + if stale(fav.item_id): + fav.item_id = track.id + fav.name = track.title + relinked += 1 + for rat in Rating.query.filter_by(item_type="track", isrc=track.isrc).all(): + if stale(rat.item_id): + rat.item_id = track.id + relinked += 1 + for bm in Bookmark.query.filter_by(item_type="track", isrc=track.isrc).all(): + if stale(bm.item_id): + bm.item_id = track.id + bm.title = track.title + relinked += 1 + for scrobble in Scrobble.query.filter_by(isrc=track.isrc).all(): + if stale(scrobble.track_id): + scrobble.track_id = track.id + relinked += 1 + db.session.flush() + return relinked + + +# --------------------------------------------------------------------------- +# summary +# --------------------------------------------------------------------------- + +def summary(user_id) -> dict: + """Per-account state counts (cheap dashboard/integration call).""" + user_id = _require_account(user_id) + queue = PlayQueue.query.filter_by(user_id=user_id).first() + return { + "playlists": Playlist.query.filter_by(user_id=user_id).count(), + "favorites": Favorite.query.filter_by(user_id=user_id).count(), + "ratings": Rating.query.filter_by(user_id=user_id).count(), + "bookmarks": Bookmark.query.filter_by(user_id=user_id).count(), + "scrobbles": Scrobble.query.filter_by(user_id=user_id).count(), + "queue_items": (PlayQueueEntry.query.filter_by(user_id=user_id).count() + if queue else 0), + } + + +# --------------------------------------------------------------------------- +# small helpers +# --------------------------------------------------------------------------- + +def _iso(value): + return value.isoformat() if value is not None else None + + +def _parse_datetime(value: datetime) -> datetime: + if isinstance(value, datetime): + return value + try: + text = str(value).strip().replace("Z", "+00:00") + parsed = datetime.fromisoformat(text) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + except ValueError: + raise ValidationError(f"invalid ISO-8601 datetime: {value!r}") diff --git a/tests/architecture/test_user_state_and_integration_api.py b/tests/architecture/test_user_state_and_integration_api.py new file mode 100644 index 0000000..79c62a8 --- /dev/null +++ b/tests/architecture/test_user_state_and_integration_api.py @@ -0,0 +1,679 @@ +"""Architecture tests: generic accounts, per-account user state, and the +provider-neutral integration API. + +Covers the contract the core must honour for any external server/client +integration (see docs/integration-api.md): + +* accounts authenticate independently, programmatically (hashed API tokens) + and via session; +* every user-state object is owned by exactly one account and reads/writes are + scoped to the authenticated account — cross-account access is refused; +* playlist ordering is deterministic, positions are unique per owner, and + reordering can never leave two items on the same position; +* favorites, ratings, bookmarks, scrobble history and the saved queue work; +* state references fnack's internal artist/album/track ids, survives library + metadata/provider changes, survives hard deletion of the referenced row + (snapshot + ISRC re-link), and provider identities stay opaque; +* library queries are provider-neutral and paginated in SQL; +* the new tables are created additively on an existing installation; +* core carries no client- or product-specific terminology. + +Run from the repo root: + + .venv/bin/python tests/architecture/test_user_state_and_integration_api.py +""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(ROOT)) + +_COUNTER = {"n": 0} + + +def _make_app(): + """Throwaway app with the real guard, accounts blueprint and integration + API wired to an in-memory database.""" + from flask import Flask + from models import db + + _COUNTER["n"] += 1 + app = Flask(__name__, template_folder=str(ROOT / "templates")) + app.config["SECRET_KEY"] = "test-secret" + app.config["SQLALCHEMY_DATABASE_URI"] = ( + f"sqlite:///file:fnack_userstate_{_COUNTER['n']}" + "?mode=memory&cache=shared&uri=true") + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + db.init_app(app) + + from services.accounts import auth_guard, build_accounts_blueprint + from services.integration_api import build_integration_blueprint + app.before_request(auth_guard) + app.register_blueprint(build_accounts_blueprint()) + app.register_blueprint(build_integration_blueprint()) + return app, db + + +def _tables(db): + import models # noqa: F401 — registers every core table + db.create_all() + + +def _two_accounts(): + from services.accounts import create_user + return create_user("alice", "alice-pass-123", role="user"), \ + create_user("bob", "bob-pass-1234", role="user") + + +def _seed_library(artist_count=1, albums_per_artist=1, tracks_per_album=2, + prefix="Lib", provider="community.test-provider"): + """Create a small library and return its internal ids.""" + from models import Album, Artist, Track, db + artist_ids, album_ids, track_ids = [], [], [] + for a in range(artist_count): + artist = Artist(name=f"{prefix} Artist {a}", external_id=f"a{a}", + provider_id=provider) + db.session.add(artist) + db.session.flush() + artist_ids.append(artist.id) + for b in range(albums_per_artist): + album = Album(artist_id=artist.id, name=f"{prefix} Album {a}-{b}", year=2020 + b, + external_id=f"al{a}-{b}", provider_id=provider) + db.session.add(album) + db.session.flush() + album_ids.append(album.id) + for t in range(tracks_per_album): + track = Track(album_id=album.id, artist_id=artist.id, + title=f"{prefix} Track {a}-{b}-{t}", + track_number=t + 1, duration=180.0 + t, + external_id=f"t{a}-{b}-{t}", provider_id=provider, + isrc=f"ISRC{a}{b}{t:04d}", status="missing") + db.session.add(track) + db.session.flush() + track_ids.append(track.id) + db.session.commit() + return {"artists": artist_ids, "albums": album_ids, "tracks": track_ids} + + +# --------------------------------------------------------------------------- +# accounts + credentials +# --------------------------------------------------------------------------- + +def test_accounts_authenticate_independently_with_hashed_tokens() -> None: + from services import user_state + from services.accounts import (create_api_token, list_api_tokens, revoke_api_token, + user_by_username, verify_api_token) + app, db = _make_app() + with app.app_context(): + _tables(db) + alice, bob = _two_accounts() + + plaintext, row = create_api_token(alice, label="navmuse-sync") + assert plaintext.startswith("fnack_") + # The secret is never stored: only a hash + display prefix. + from models import ApiToken + stored = db.session.get(ApiToken, row.id) + assert stored.token_hash != plaintext + assert plaintext not in stored.token_hash + assert stored.prefix in plaintext + assert list_api_tokens(alice)[0]["label"] == "navmuse-sync" + assert "token" not in list_api_tokens(alice)[0] + + # Each account resolves to itself, independently. + assert verify_api_token(plaintext).id == alice.id + bob_token, _ = create_api_token(bob) + assert verify_api_token(bob_token).id == bob.id + assert verify_api_token("fnack_not-a-real-token") is None + + # Revocation is account-scoped: bob cannot revoke alice's token. + assert revoke_api_token(bob, row.id) is False + assert verify_api_token(plaintext) is not None + assert revoke_api_token(alice, row.id) is True + assert verify_api_token(plaintext) is None + + # Usernames/passwords are independent per account. + from services.accounts import verify_password + assert verify_password(user_by_username("alice"), "alice-pass-123") + assert not verify_password(user_by_username("alice"), "bob-pass-1234") + assert user_state.summary(alice.id)["playlists"] == 0 + + +# --------------------------------------------------------------------------- +# isolation +# --------------------------------------------------------------------------- + +def test_two_accounts_have_fully_isolated_state() -> None: + from services import user_state + app, db = _make_app() + with app.app_context(): + _tables(db) + alice, bob = _two_accounts() + lib = _seed_library() + track_id = lib["tracks"][0] + + alice_playlist = user_state.create_playlist(alice.id, "Alice mix", track_ids=[track_id]) + user_state.star(alice.id, "track", track_id) + user_state.set_rating(alice.id, "track", track_id, 5) + user_state.set_bookmark(alice.id, "track", track_id, 42_000, comment="alice") + user_state.record_scrobble(alice.id, track_id) + user_state.save_queue(alice.id, [track_id], changed_by="alice-client") + + # Bob sees nothing of Alice's. + assert user_state.list_playlists(bob.id)["total"] == 0 + assert user_state.list_favorites(bob.id)["total"] == 0 + assert user_state.list_ratings(bob.id)["total"] == 0 + assert user_state.list_bookmarks(bob.id)["total"] == 0 + assert user_state.list_scrobbles(bob.id)["total"] == 0 + assert user_state.get_queue(bob.id)["items"] == [] + assert user_state.is_starred(bob.id, "track", track_id) is False + + # …and cannot reach them by id either (NotFound, not 403: existence of + # another account's object is never revealed). + for call in ( + lambda: user_state.get_playlist(bob.id, alice_playlist["id"]), + lambda: user_state.update_playlist(bob.id, alice_playlist["id"], name="hijack"), + lambda: user_state.delete_playlist(bob.id, alice_playlist["id"]), + lambda: user_state.add_tracks(bob.id, alice_playlist["id"], [track_id]), + lambda: user_state.reorder_items(bob.id, alice_playlist["id"], []), + ): + try: + call() + raise AssertionError("cross-account playlist access must be refused") + except user_state.NotFound: + pass + + # Alice's state is untouched by Bob's attempts. + assert user_state.get_playlist(alice.id, alice_playlist["id"])["name"] == "Alice mix" + assert user_state.get_queue(alice.id)["total"] == 1 + + +# --------------------------------------------------------------------------- +# playlists +# --------------------------------------------------------------------------- + +def test_playlist_crud_and_deterministic_ordering() -> None: + from models import PlaylistItem, db + from services import user_state + app, db = _make_app() + with app.app_context(): + _tables(db) + alice, _bob = _two_accounts() + lib = _seed_library(tracks_per_album=4) + t = lib["tracks"] + + playlist = user_state.create_playlist(alice.id, "Ordered") + pid = playlist["id"] + assert playlist["items"] == [] + + user_state.add_tracks(alice.id, pid, [t[0], t[1], t[2]]) + got = user_state.get_playlist(alice.id, pid) + assert [i["track_id"] for i in got["items"]] == [t[0], t[1], t[2]] + assert [i["position"] for i in got["items"]] == [0, 1, 2] + item_ids = [i["item_id"] for i in got["items"]] + + # Insert at a position -> deterministic, compacted ordering. + user_state.add_tracks(alice.id, pid, [t[3]], position=1) + got = user_state.get_playlist(alice.id, pid) + assert [i["track_id"] for i in got["items"]] == [t[0], t[3], t[1], t[2]] + assert [i["position"] for i in got["items"]] == [0, 1, 2, 3] + + # Reorder: exact item set required, duplicates/extras refused. + reversed_ids = list(reversed([i["item_id"] for i in got["items"]])) + user_state.reorder_items(alice.id, pid, reversed_ids) + got = user_state.get_playlist(alice.id, pid) + assert [i["position"] for i in got["items"]] == [0, 1, 2, 3] + for bad in ([reversed_ids[0], reversed_ids[0], *reversed_ids[2:]], + reversed_ids[:-1], [*reversed_ids, 99999]): + try: + user_state.reorder_items(alice.id, pid, bad) + raise AssertionError("invalid order must be refused") + except user_state.ValidationError: + pass + + # Positions stay unique per playlist in the database itself. + positions = [row[0] for row in db.session.query(PlaylistItem.position) + .filter_by(playlist_id=pid).all()] + assert sorted(positions) == list(range(len(positions))) + + # Removing re-compacts; replacing rewrites in order. + user_state.remove_item(alice.id, pid, got["items"][0]["item_id"]) + got = user_state.get_playlist(alice.id, pid) + assert [i["position"] for i in got["items"]] == list(range(got["total"])) + user_state.set_items(alice.id, pid, [t[3], t[0]]) + got = user_state.get_playlist(alice.id, pid) + assert [i["track_id"] for i in got["items"]] == [t[3], t[0]] + + # Rename + delete. + assert user_state.update_playlist(alice.id, pid, name="Renamed")["name"] == "Renamed" + assert user_state.delete_playlist(alice.id, pid) is True + assert user_state.list_playlists(alice.id)["total"] == 0 + + # Unknown library ids are refused before anything is persisted. + try: + user_state.add_tracks(alice.id, user_state.create_playlist(alice.id, "x")["id"], + [999999]) + raise AssertionError("unknown track id must be refused") + except user_state.ValidationError: + pass + + +# --------------------------------------------------------------------------- +# favorites / ratings / bookmarks / scrobbles / queue +# --------------------------------------------------------------------------- + +def test_favorites_ratings_bookmarks_scrobbles_and_queue() -> None: + from services import user_state + app, db = _make_app() + with app.app_context(): + _tables(db) + alice, _bob = _two_accounts() + lib = _seed_library() + artist_id, album_id, track_id = lib["artists"][0], lib["albums"][0], lib["tracks"][0] + + # favorites (stars) across object types, idempotent + user_state.star(alice.id, "artist", artist_id) + user_state.star(alice.id, "album", album_id) + user_state.star(alice.id, "track", track_id) + user_state.star(alice.id, "track", track_id) + assert user_state.list_favorites(alice.id)["total"] == 3 + assert user_state.list_favorites(alice.id, item_type="track")["total"] == 1 + assert user_state.is_starred(alice.id, "track", track_id) is True + assert user_state.unstar(alice.id, "track", track_id) is True + assert user_state.unstar(alice.id, "track", track_id) is False + assert user_state.list_favorites(alice.id)["total"] == 2 + + # ratings 1..5, cleared with None, validated + assert user_state.set_rating(alice.id, "track", track_id, 4)["rating"] == 4 + assert user_state.get_rating(alice.id, "track", track_id) == 4 + assert user_state.set_rating(alice.id, "album", album_id, 5)["rating"] == 5 + assert user_state.list_ratings(alice.id)["total"] == 2 + assert user_state.set_rating(alice.id, "track", track_id, None)["rating"] is None + assert user_state.get_rating(alice.id, "track", track_id) is None + for bad in (0 - 1, 6, "loud"): + try: + user_state.set_rating(alice.id, "track", track_id, bad) + raise AssertionError(f"rating {bad!r} must be refused") + except user_state.ValidationError: + pass + try: + user_state.set_rating(alice.id, "playlist", 1, 3) + raise AssertionError("unsupported item type must be refused") + except user_state.ValidationError: + pass + + # bookmarks + bm = user_state.set_bookmark(alice.id, "track", track_id, 61_500, comment="chapter 2") + assert bm["position_ms"] == 61_500 and bm["comment"] == "chapter 2" + user_state.set_bookmark(alice.id, "track", track_id, 62_000) + assert user_state.get_bookmark(alice.id, "track", track_id)["position_ms"] == 62_000 + assert user_state.list_bookmarks(alice.id)["total"] == 1 + assert user_state.delete_bookmark(alice.id, "track", track_id) is True + assert user_state.get_bookmark(alice.id, "track", track_id) is None + try: + user_state.set_bookmark(alice.id, "artist", artist_id, 10) + raise AssertionError("bookmarks do not apply to artists") + except user_state.ValidationError: + pass + + # scrobble history + most-played + for _ in range(3): + user_state.record_scrobble(alice.id, track_id) + user_state.record_scrobble(alice.id, lib["tracks"][1], submission=False) + assert user_state.list_scrobbles(alice.id)["total"] == 4 + assert user_state.list_scrobbles(alice.id, limit=2)["items"][0]["submission"] is False + top = user_state.play_counts(alice.id) + assert top[0]["track_id"] == track_id and top[0]["plays"] == 3 + + # saved queue with cursor, replaced atomically + queue = user_state.save_queue(alice.id, [track_id, lib["tracks"][1]], + current_index=1, position_ms=1_500, + changed_by="client-x") + assert queue["total"] == 2 and queue["current_index"] == 1 + assert queue["position_ms"] == 1_500 and queue["changed_by"] == "client-x" + assert [i["position"] for i in queue["items"]] == [0, 1] + # duplicate tracks are allowed; positions stay unique + queue = user_state.save_queue(alice.id, [track_id, track_id], current_index=5) + assert queue["total"] == 2 and queue["current_index"] == 1 + assert user_state.clear_queue(alice.id) is True + assert user_state.get_queue(alice.id)["items"] == [] + + # summary reflects everything the account owns + summary = user_state.summary(alice.id) + assert summary["favorites"] == 2 and summary["ratings"] == 1 + assert summary["scrobbles"] == 4 and summary["queue_items"] == 0 + + +# --------------------------------------------------------------------------- +# library churn +# --------------------------------------------------------------------------- + +def test_state_survives_track_deletion_and_relinks_by_isrc() -> None: + from models import Track, db + from services import user_state + app, db = _make_app() + with app.app_context(): + _tables(db) + alice, bob = _two_accounts() + lib = _seed_library() + track_id = lib["tracks"][0] + album_id = lib["albums"][0] + + playlist = user_state.create_playlist(alice.id, "Keep me", track_ids=[track_id]) + user_state.star(alice.id, "track", track_id) + user_state.set_rating(alice.id, "track", track_id, 5) + user_state.set_bookmark(alice.id, "track", track_id, 12_000) + user_state.record_scrobble(alice.id, track_id) + user_state.save_queue(alice.id, [track_id]) + user_state.star(bob.id, "track", track_id) + + # Hard-delete the library row the way a sync prune / artist delete does: + # snapshot first, then remove. + user_state.snapshot_referenced_tracks([track_id]) + isrc = db.session.get(Track, track_id).isrc + db.session.delete(db.session.get(Track, track_id)) + db.session.commit() + + # Nothing was lost; entries stay readable and are marked unavailable. + got = user_state.get_playlist(alice.id, playlist["id"]) + assert got["items"][0]["available"] is False + assert got["items"][0]["title"] == "Lib Track 0-0-0" + assert got["items"][0]["track_id"] == track_id # internal id retained + favs = user_state.list_favorites(alice.id)["items"] + assert favs[0]["available"] is False and favs[0]["isrc"] == isrc + assert user_state.list_scrobbles(alice.id)["items"][0]["title"] == "Lib Track 0-0-0" + assert user_state.get_queue(alice.id)["items"][0]["track_id"] == track_id + + # The provider re-creates the same recording under a NEW internal id. + replacement = Track(album_id=album_id, artist_id=lib["artists"][0], + title="Lib Track 0-0-0", track_number=1, duration=180.0, + isrc=isrc, provider_id="community.other-provider", + external_id="replacement-1", status="missing") + db.session.add(replacement) + db.session.commit() + assert replacement.id != track_id + relinked = user_state.relink_tracks([replacement.id]) + assert relinked >= 5, f"expected every state row to re-attach, got {relinked}" + + got = user_state.get_playlist(alice.id, playlist["id"]) + assert got["items"][0]["track_id"] == replacement.id + assert got["items"][0]["available"] is True + assert user_state.get_rating(alice.id, "track", replacement.id) == 5 + assert user_state.get_bookmark(alice.id, "track", replacement.id)["position_ms"] == 12_000 + assert user_state.is_starred(alice.id, "track", replacement.id) is True + assert user_state.get_queue(alice.id)["items"][0]["track_id"] == replacement.id + assert user_state.list_scrobbles(alice.id)["items"][0]["track_id"] == replacement.id + # Bob's own star survived too (state is per-account, not global). + assert user_state.is_starred(bob.id, "track", replacement.id) is True + + +def test_provider_identities_stay_opaque_and_separate() -> None: + from models import Track, db + from services import library_query, user_state + app, db = _make_app() + with app.app_context(): + _tables(db) + alice, _bob = _two_accounts() + lib = _seed_library(provider="community.first") + track_id = lib["tracks"][0] + playlist = user_state.create_playlist(alice.id, "P", track_ids=[track_id]) + user_state.star(alice.id, "track", track_id) + + # Provider identities are opaque, separately scoped values — never a + # primary reference for user state. + row = library_query.get_track(track_id) + assert row["provider_id"] == "community.first" and row["external_id"] == "t0-0-0" + result = library_query.tracks(provider_never_used=None) if False else None + track = db.session.get(Track, track_id) + track.provider_id = "community.replacement" + track.external_id = "different-id" + track.title = "Renamed by provider" + db.session.commit() + + # State is keyed on the internal id, so provider/metadata churn is + # invisible to it (the live title simply wins on read). + got = user_state.get_playlist(alice.id, playlist["id"]) + assert got["items"][0]["track_id"] == track_id + assert got["items"][0]["title"] == "Renamed by provider" + assert user_state.is_starred(alice.id, "track", track_id) is True + assert result is None + + # The user-state and library-query services name no provider. + for module in ("services/user_state.py", "services/library_query.py", + "services/integration_api.py"): + source = (ROOT / module).read_text(encoding="utf-8").lower() + for provider in ("deezer", "spotify", "musicbrainz", "itunes", "acoustid", + "navidrome", "subsonic"): + assert provider not in source, f"{module} must stay provider-neutral ({provider!r})" + + +# --------------------------------------------------------------------------- +# library queries +# --------------------------------------------------------------------------- + +def test_paginated_provider_neutral_library_queries() -> None: + from services import library_query + app, db = _make_app() + with app.app_context(): + _tables(db) + lib = _seed_library(artist_count=7, albums_per_artist=2, tracks_per_album=3, + prefix="Zeta") + + # Pagination is bounded and reports totals without loading the library. + page1 = library_query.tracks(limit=5, offset=0) + assert page1["total"] == 7 * 2 * 3 + assert len(page1["items"]) == 5 + assert page1["has_more"] is True + page2 = library_query.tracks(limit=5, offset=5) + assert {i["id"] for i in page1["items"]}.isdisjoint({i["id"] for i in page2["items"]}) + last = library_query.tracks(limit=5, offset=40) + assert last["has_more"] is False and len(last["items"]) == 2 + + # Sorting + order are whitelisted. + titles_desc = [i["title"] for i in library_query.tracks(limit=3, sort="title", + order="desc")["items"]] + assert titles_desc == sorted(titles_desc, reverse=True) + for bad in ({"sort": "title; DROP TABLE tracks"}, {"order": "sideways"}): + try: + library_query.tracks(**bad) + raise AssertionError(f"{bad} must be refused") + except library_query.LibraryQueryError: + pass + + # Search across track/album/artist level. + assert library_query.tracks(q="Zeta Track 1-")["total"] == 6 + assert library_query.albums(q="Zeta Album 2")["total"] == 2 + assert library_query.artists(q="Zeta Artist 3")["total"] == 1 + assert library_query.tracks(q="Zeta Artist 0")["total"] == 6 # matches by artist name + + # Filters + direct lookup + counts. + album_id = lib["albums"][0] + assert library_query.tracks(album_id=album_id)["total"] == 3 + assert library_query.albums(artist_id=lib["artists"][0])["total"] == 2 + assert library_query.tracks(downloaded=True)["total"] == 0 + assert library_query.get_album(album_id)["track_count"] == 3 + assert library_query.get_artist(lib["artists"][0])["album_count"] == 2 + assert library_query.get_track(lib["tracks"][0])["id"] == lib["tracks"][0] + assert library_query.get_track(999999) is None + stats = library_query.stats() + assert stats["artists"] == 7 and stats["tracks"] == 42 + + +# --------------------------------------------------------------------------- +# HTTP boundary +# --------------------------------------------------------------------------- + +def test_integration_api_end_to_end_and_cross_account_refusal() -> None: + app, db = _make_app() + with app.app_context(): + _tables(db) + from models import AppSetting + _two_accounts() + lib = _seed_library() + track_id = lib["tracks"][0] + db.session.add(AppSetting(key="api_key", value="machine-key-123")) + db.session.commit() + + client = app.test_client() + + # meta is public; everything else needs an account. + assert client.get("/api/integration/v1/meta").status_code == 200 + assert client.get("/api/integration/v1/me").status_code == 401 + assert client.get("/api/integration/v1/library/tracks").status_code == 401 + assert client.get("/api/integration/v1/playlists").status_code == 401 + + # Token exchange (public endpoint) — wrong password refused. + assert client.post("/api/integration/v1/auth/token", json={ + "username": "alice", "password": "nope"}).status_code == 401 + alice_token = client.post("/api/integration/v1/auth/token", json={ + "username": "alice", "password": "alice-pass-123", "label": "test"}).get_json()["token"] + bob_token = client.post("/api/integration/v1/auth/token", json={ + "username": "bob", "password": "bob-pass-1234"}).get_json()["token"] + alice = {"Authorization": f"Bearer {alice_token}"} + bob = {"Authorization": f"Bearer {bob_token}"} + + me = client.get("/api/integration/v1/me", headers=alice).get_json() + assert me["account"]["username"] == "alice" + assert me["library"]["tracks"] >= 1 + + # Library access over HTTP. + tracks = client.get("/api/integration/v1/library/tracks?limit=1", headers=alice).get_json() + assert tracks["total"] >= 1 and len(tracks["items"]) == 1 + assert client.get(f"/api/integration/v1/library/tracks/{track_id}", + headers=alice).status_code == 200 + assert client.get("/api/integration/v1/library/tracks/999999", + headers=alice).status_code == 404 + + # Playlist CRUD over HTTP, scoped to the token's account. + created = client.post("/api/integration/v1/playlists", headers=alice, + json={"name": "From HTTP", "track_ids": [track_id]}) + assert created.status_code == 201 + playlist_id = created.get_json()["id"] + assert client.get("/api/integration/v1/playlists", headers=alice).get_json()["total"] == 1 + + # Cross-account access is refused at the HTTP layer too. + assert client.get(f"/api/integration/v1/playlists/{playlist_id}", + headers=bob).status_code == 404 + assert client.delete(f"/api/integration/v1/playlists/{playlist_id}", + headers=bob).status_code == 404 + assert client.post(f"/api/integration/v1/playlists/{playlist_id}/items", headers=bob, + json={"track_ids": [track_id]}).status_code == 404 + assert client.get("/api/integration/v1/playlists", headers=bob).get_json()["total"] == 0 + + # State endpoints round-trip. + assert client.put(f"/api/integration/v1/favorites/track/{track_id}", + headers=alice).status_code == 200 + assert client.get("/api/integration/v1/favorites", headers=alice).get_json()["total"] == 1 + assert client.put(f"/api/integration/v1/ratings/track/{track_id}", headers=alice, + json={"rating": 3}).get_json()["rating"] == 3 + assert client.put(f"/api/integration/v1/bookmarks/track/{track_id}", headers=alice, + json={"position_ms": 5000}).status_code == 200 + assert client.post("/api/integration/v1/scrobbles", headers=alice, + json={"track_id": track_id}).status_code == 201 + assert client.put("/api/integration/v1/queue", headers=alice, + json={"track_ids": [track_id], "current_index": 0, + "changed_by": "http-test"}).get_json()["total"] == 1 + summary = client.get("/api/integration/v1/state/summary", headers=alice).get_json() + assert summary == {"playlists": 1, "favorites": 1, "ratings": 1, "bookmarks": 1, + "scrobbles": 1, "queue_items": 1} + + # Unknown library ids are rejected with 400 (validated before persistence). + assert client.post("/api/integration/v1/playlists", headers=alice, + json={"name": "bad", "track_ids": [999999]}).status_code == 400 + + # The machine API key carries no account, so it cannot reach account state. + machine = {"X-API-Key": "machine-key-123"} + assert client.get("/api/integration/v1/me", headers=machine).status_code == 401 + assert client.get("/api/integration/v1/playlists", headers=machine).status_code == 401 + + # Session login (browser) sees the same account state as its token. + session_client = app.test_client() + assert session_client.post("/login", data={ + "username": "alice", "password": "alice-pass-123"}).status_code == 302 + assert session_client.get("/api/integration/v1/playlists").get_json()["total"] == 1 + + # Token listing + revocation are account-scoped. + tokens = client.get("/api/integration/v1/tokens", headers=alice).get_json()["items"] + assert len(tokens) == 1 and "token" not in tokens[0] + bob_tokens = client.get("/api/integration/v1/tokens", headers=bob).get_json()["items"] + assert client.delete(f"/api/integration/v1/tokens/{tokens[0]['id']}", + headers=bob).status_code == 404 + assert client.delete(f"/api/integration/v1/tokens/{bob_tokens[0]['id']}", + headers=bob).status_code == 200 + assert client.delete(f"/api/integration/v1/tokens/{tokens[0]['id']}", + headers=alice).status_code == 200 + assert client.get("/api/integration/v1/me", headers=alice).status_code == 401 + + +# --------------------------------------------------------------------------- +# migrations + terminology +# --------------------------------------------------------------------------- + +def test_migration_creates_user_state_tables_on_existing_install() -> None: + from models import Album, Artist, Track, db + from services.schema_migrations import run_schema_migrations + app, db = _make_app() + with app.app_context(): + _tables(db) + # Simulate an installation from before this feature: library + account + # exist, the new tables do not. + alice, _bob = _two_accounts() + lib = _seed_library() + new_tables = ["api_tokens", "playlists", "playlist_items", "favorites", + "ratings", "bookmarks", "scrobbles", "play_queues", + "play_queue_entries"] + for table in new_tables: + db.session.execute(db.text(f"DROP TABLE IF EXISTS {table}")) + db.session.commit() + existing_tracks = Track.query.count() + + run_schema_migrations(engine=db.engine) + + from sqlalchemy import inspect + present = set(inspect(db.engine).get_table_names()) + missing = [t for t in new_tables if t not in present] + assert not missing, f"migration must create {missing}" + + # Existing data is intact and usable. + assert Track.query.count() == existing_tracks + assert Artist.query.count() == lib["artists"].__len__() + assert Album.query.count() == lib["albums"].__len__() + from services import user_state + assert user_state.list_playlists(alice.id)["total"] == 0 + assert user_state.create_playlist(alice.id, "post-migration")["name"] == "post-migration" + + +def test_core_carries_no_client_or_product_specific_terminology() -> None: + """fnack core stays protocol-agnostic: no server/client product names, no + protocol vocabulary. (plugins/essential.py is allow-listed: it records the + historical removal of a plugin by name; wayfinder/ is historical records.)""" + forbidden = ("subsonic", "arpeggi", "navmuse", "audiomuse", "symfonium", + "feishin", "dsub", "opensubsonic") + # plugins/essential.py is allow-listed: it records the historical removal + # of a plugin by name (catalog history, not core vocabulary). + allowed = {ROOT / "plugins" / "essential.py"} + files = [ROOT / "app.py", ROOT / "models.py"] + files += sorted((ROOT / "services").glob("*.py")) + files += sorted((ROOT / "plugins").glob("*.py")) + files += [ROOT / "docs" / "architecture.md", ROOT / "docs" / "integration-api.md"] + files = [f for f in files if f not in allowed] + for path in files: + if not path.exists(): + continue + text = path.read_text(encoding="utf-8").lower() + for term in forbidden: + assert term not in text, f"{path.relative_to(ROOT)} must not contain {term!r}" + + +if __name__ == "__main__": + test_accounts_authenticate_independently_with_hashed_tokens() + test_two_accounts_have_fully_isolated_state() + test_playlist_crud_and_deterministic_ordering() + test_favorites_ratings_bookmarks_scrobbles_and_queue() + test_state_survives_track_deletion_and_relinks_by_isrc() + test_provider_identities_stay_opaque_and_separate() + test_paginated_provider_neutral_library_queries() + test_integration_api_end_to_end_and_cross_account_refusal() + test_migration_creates_user_state_tables_on_existing_install() + test_core_carries_no_client_or_product_specific_terminology() + print("test_user_state_and_integration_api: PASSED")