Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand All @@ -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)
Expand Down Expand Up @@ -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()

Expand Down
34 changes: 34 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
227 changes: 227 additions & 0 deletions docs/integration-api.md
Original file line number Diff line number Diff line change
@@ -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/<id>` | 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/<id>` | — (with album/track counts) |
| `GET` | `/library/albums` | `q`, `artist_id`, `offset`, `limit`, `sort` (`name`,`year`,`created`,`id`), `order` |
| `GET` | `/library/albums/<id>` | — (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/<id>` | — (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/<id>` | Full ordered items (`offset`/`limit` optional). |
| `PATCH` | `/playlists/<id>` | `{"name"?, "comment"?}`. |
| `DELETE` | `/playlists/<id>` | Removes the playlist and its items. |
| `POST` | `/playlists/<id>/items` | `{"track_ids": [...], "position"?}` → append or insert. |
| `PUT` | `/playlists/<id>/items` | `{"item_ids": [...]}` — the exact full order. |
| `DELETE` | `/playlists/<id>/items/<item_id>` | 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/<type>/<id>` | `{"starred": bool}` |
| `PUT` | `/favorites/<type>/<id>` | Star (idempotent). |
| `DELETE` | `/favorites/<type>/<id>` | Unstar. |

### Ratings

| Method | Path | Notes |
|---|---|---|
| `GET` | `/ratings` | `type`, `offset`, `limit`. |
| `GET` | `/ratings/<type>/<id>` | `{"rating": 1..5 \| null}` |
| `PUT` | `/ratings/<type>/<id>` | `{"rating": 1..5}` (other values → `400`). |
| `DELETE` | `/ratings/<type>/<id>` | Clears the rating. |

### Bookmarks

| Method | Path | Notes |
|---|---|---|
| `GET` | `/bookmarks` | `offset`, `limit`. |
| `GET` | `/bookmarks/<type>/<id>` | `404` when unset. |
| `PUT` | `/bookmarks/<type>/<id>` | `{"position_ms": int, "comment"?}`; `type` is `track`\|`album`. |
| `DELETE` | `/bookmarks/<type>/<id>` | 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}"
```
Loading
Loading