# discs.fyi — agent skill

API version: 9bb7b014

discs.fyi is a personal physical-media (Blu-ray / 4K / DVD) collection tracker.
This document tells an AI agent how to authenticate as a discs.fyi user and
read and update their collection over a small JSON API: list what they own,
look up a title, pick something to watch tonight, log that they watched
something, catalogue a new disc, edit or move a release, manage shelves, and
remove things.

If the user just said something like "list my action movies from discs.fyi" or
"mark Blade as watched yesterday", follow the steps below: authenticate once
(reuse the saved token afterwards), then call the API.

## Reuse a saved token first (do this before anything else)

Tokens are long-lived, so you only ever need to run the device flow once per
user. Before starting it, look wherever you persist secrets for an existing
discs.fyi token:

- If you can write files, a good spot is `~/.config/discs-fyi/token`.
- If you're Claude Code, your persistent memory works too.
- If you can't use a filesystem (e.g. a mobile or otherwise sandboxed app), use
  whatever durable storage you do have — a secrets store, an environment
  variable, your own long-term memory, or somewhere else you can keep it.

If you find a saved token, skip the device flow entirely and go straight to the
API. Only fall into the device flow when you have no token, when an API call
returns 401 (the token was revoked), or when a write returns 403
`insufficient_scope` (the token was issued without the scope you now need —
see "Scopes").

## Check the API version on every response

The "API version" at the top of this document identifies the exact revision of
these instructions. Every API response carries the server's current version in
an `X-Discs-Api-Version` header, and GET /api/me also returns it as
"api_version". Save the version alongside your token. If a response ever
reports a version different from the one you saved, the API has changed since
you read this document — routes or parameters may have been added, and
responses may have changed shape — so fetch https://discs.fyi/skill again before
continuing, then save the new version. This is cheap (compare two strings) and
is the only way you'll learn about a change; nothing else announces it.

## Scopes — ask for what you'll need

A token carries a set of scopes, granted by the user when they approve you.
There are three:

- `read` — every token has it. Browse collections, releases, shelves and watch
  history.
- `write` — log and correct watches, add new releases, edit release details
  and status (including wishlist), move releases between shelves and
  collections, create and rename shelves.
- `delete` — remove releases and delete shelves.

Request scopes when you start the device flow (step 1 below). Ask for what the
user's request actually needs — a user asked to approve "read and log watches"
will say yes more readily than one asked for delete access they didn't need. The
user sees each requested scope as a checkbox on the approval page and may
untick some, so **the token you receive may have fewer scopes than you asked
for**: read the `scope` field of the token response and remember it alongside
the token. If you later need a scope the token lacks, the API answers 403
`insufficient_scope` naming the missing scope; explain to the user which
permission was withheld and re-run the device flow requesting it. A new
approval mints a new token; the old one keeps working for what it had.

Tokens issued before scopes existed are read-only.

## Authenticating — OAuth 2.0 Device Flow (RFC 8628)

The user signs in with their browser; the agent never handles their password.

1. Request a code, naming the scopes you need (space-separated; `read` is
   implied and may be omitted; omit `scope` entirely for read-only):

   POST https://discs.fyi/api/auth/device/code
   Content-Type: application/json

   { "scope": "read write" }

   Response (200, application/json):
   {
     "device_code": "<secret handle for polling>",
     "user_code": "WDJB-MJHT",
     "verification_uri": "https://discs.fyi/device",
     "verification_uri_complete": "https://discs.fyi/device?user_code=WDJB-MJHT",
     "expires_in": 600,
     "interval": 5,
     "scope": "read write"
   }

   An unknown scope is rejected: 400 {"error": "invalid_scope", "valid": [...]}.

2. Ask the user to approve. Print the "verification_uri_complete" link (it
   pre-fills the code) and tell them to open it, review the permissions, and
   click Approve. Also show the "user_code" in case they use the plain
   "verification_uri".

3. Poll for the token every "interval" seconds:

   POST https://discs.fyi/api/auth/device/token
   Content-Type: application/x-www-form-urlencoded

   device_code=<the device_code from step 1>

   - Not approved yet → 400 {"error": "authorization_pending"} — keep polling.
   - User denied      → 400 {"error": "access_denied"} — stop.
   - Code expired     → 400 {"error": "expired_token"} — stop; start over at step 1.
   - Approved         → 200 {"access_token": "<token>", "token_type": "Bearer", "scope": "read write"}

   "scope" is what the user actually granted — it may be narrower than what you
   asked for. Save it with the token.

4. Use the token on every API call:

   Authorization: Bearer <access_token>

   Save it durably now (see "Reuse a saved token first" above), together with
   the granted "scope" and the API version at the top of this document, so
   future sessions skip straight to the API. Treat it like a password: it grants access to the
   user's collection — with `write` or `delete`, the ability to change it — so
   don't commit it to a repo or print it in logs. If any API call later returns
   401, the token has been revoked — discard the saved copy and start over at
   step 1.

## API conventions

All endpoints require the bearer token and speak JSON. Write requests send a
JSON object body (`Content-Type: application/json`). Errors are
`{ "error": "<code>" }` with an appropriate status and usually a "message":

- 400 — a field is missing or malformed; the "error" code names it (e.g.
  `invalid_watched_at`) and "valid" lists allowed values where there is a fixed
  set. Fix the request; don't retry as-is.
- 401 `unauthorized` — no or revoked token. Re-run the device flow.
- 403 `insufficient_scope` — the token lacks the "scope" named in the body.
  Re-run the device flow requesting it.
- 404 `not_found` — the thing doesn't exist or isn't in a collection the user
  belongs to. The API doesn't distinguish the two.
- 409 — a conflict explained in "message" (currently only `granularity_locked`
  when adding TV).

Ids: a `tmdb_id` identifies a title (from TMDB); a `release_id` identifies one
disc the user has of it; a `watch` "id" identifies one logged viewing. Get
release and watch ids from the read endpoints before writing.

PATCH is partial: send only the fields you want to change. Sending a field as
null clears it (where clearing is allowed). Omitted fields keep their value.

Dates are calendar dates formatted YYYY-MM-DD in the user's own day — "yesterday"
is yesterday where the user is. Always send `watched_at` explicitly rather than
relying on the default, which is today in UTC.

Logging a watch is not idempotent: two POSTs log two viewings. Before retrying
a watch that may have gone through, or when the user says "I watched X" and
they may already have logged it, check the title's watch history first.

## Reading

### GET https://discs.fyi/api/me

Confirms the token and identifies the user. "api_version" is the same value
as the X-Discs-Api-Version header — compare it with the version you saved.

   { "id": 1, "email": "you@example.com", "name": "Your Name", "api_version": "<the version at the top of this document>" }

### GET https://discs.fyi/api/movies

The user's owned movies. Optional query parameter:

- genre — case-insensitive genre name (e.g. Action, Comedy, "Science Fiction",
  Thriller, Horror, Drama). Filters to movies tagged with that genre.

Example:

   GET https://discs.fyi/api/movies?genre=Action
   Authorization: Bearer <token>

   {
     "movies": [
       {
         "tmdb_id": 603,
         "title": "The Matrix",
         "year": 1999,
         "genres": ["Action", "Science Fiction"],
         "formats": ["4K UHD"]
       }
     ],
     "count": 1
   }

### GET https://discs.fyi/api/items

The user's owned titles of any kind (movies, TV, box sets). Optional query params:

- type — one of: movie, tv, box-set. Omit for all kinds.
- genre — case-insensitive genre name (as above).

Each item has a "type" field. Example:

   GET https://discs.fyi/api/items?type=tv
   Authorization: Bearer <token>

   {
     "items": [
       { "tmdb_id": 1399, "title": "Game of Thrones", "year": 2011,
         "type": "tv", "genres": ["Sci-Fi & Fantasy", "Drama"], "formats": ["Blu-ray"] }
     ],
     "count": 1
   }

### GET https://discs.fyi/api/items/{tmdb_id}

Everything the user has on one title, looked up by its "tmdb_id" (take this from
an /api/items or /api/movies result). Returns the title's metadata, every release
(edition) of it the user owns, and their watch history aggregated across those
releases — so this answers "tell me about <title>": which discs they have, how
many times they've watched it, and when they last did. It is also where you get
the "release_id" you need to log a watch or edit a disc.

Optional query param:

- type — movie, tv, or box-set. Only needed in the rare case a tmdb_id is
  ambiguous across kinds; omit otherwise.

404 if the user owns no release of that title. Example:

   GET https://discs.fyi/api/items/36647
   Authorization: Bearer <token>

   {
     "item": {
       "tmdb_id": 36647,
       "title": "Blade",
       "year": 1998,
       "type": "movie",
       "genres": ["Action", "Horror", "Thriller"],
       "formats": ["4K UHD", "Blu-ray"],
       "releases": [
         { "release_id": 88, "format": "4K UHD", "edition_type": "Standard", "region": "A",
           "status": "owned", "season_number": null, "collection_id": 12,
           "collection_name": "Movie Wall", "shelf": "Action" }
       ],
       "watches": {
         "count": 3,
         "last_watched_at": "2026-05-30",
         "history": [ { "id": 5, "watched_at": "2026-05-30", "release_id": 88, "format": "4K UHD" } ]
       }
     }
   }

A release's "format" may be null: on a wishlist release ("status": "wishlist")
that means the user wants the title in any format; on an owned one, that the
format isn't known. "formats" lists only the formats actually given.

### GET https://discs.fyi/api/collections

The collections the user can access.

   { "collections": [ { "id": 12, "name": "Movie Wall", "description": null, "is_main": true } ], "count": 1 }

### GET https://discs.fyi/api/collections/{id}

One collection: metadata, stats, and a paginated list of its titles. Optional
query params:

- status — owned or wishlist (omit for all).
- genre — case-insensitive genre name.
- limit — page size, max 200 (default 50). offset — number to skip (default 0).

"count" is the total before paging. Each item lists its releases (editions):

   GET https://discs.fyi/api/collections/12?status=owned&limit=50
   Authorization: Bearer <token>

   {
     "collection": { "id": 12, "name": "Movie Wall", "description": null, "is_main": true },
     "stats": { "totalTitles": 153, "ownedTitles": 150, "wishlistTitles": 3, "formats": 4 },
     "items": [
       { "tmdb_id": 603, "type": "movie", "title": "The Matrix", "year": 1999,
         "releases": [ { "release_id": 88, "format": "4K UHD", "edition_type": "Standard", "status": "owned", "shelf": "Sci-Fi" } ] }
     ],
     "count": 153, "limit": 50, "offset": 0
   }

### GET https://discs.fyi/api/collections/{id}/shelves

The shelves within a collection.

   { "shelves": [ { "id": 4, "name": "Sci-Fi", "color": "#3b82f6", "item_count": 22 } ], "count": 1 }

### GET https://discs.fyi/api/releases/{id}

Full detail of one release (edition fields, which collection/shelf it's on, and
the user's watch history for it). Write endpoints that return a release use
this same "release" shape.

   { "release": { "release_id": 88, "item_title": "The Matrix", "tmdb_id": 603, "item_type": "movie",
                  "format": "4K UHD", "edition_type": "Standard", "region": "A", "edition_notes": null,
                  "barcode": null, "status": "owned", "notes": null,
                  "collection_id": 12, "collection_name": "Movie Wall",
                  "shelf_id": 4, "shelf_name": "Sci-Fi" },
     "watches": [ { "id": 5, "watched_at": "2026-05-30", "scope": "shared", "collection_name": "Movie Wall" } ] }

### GET https://discs.fyi/api/spin

Pick something to watch from what the user owns — the API form of the site's
"Spin" page, drawing from exactly the pool a user would see there, with the
same filters as query parameters. Picks are random; call it again for another.
All parameters are optional:

- collection — a collection id (from /api/collections). Omit for every
  collection, in which case a title owned in several collections counts once.
- type — movie, tv, or box-set.
- genre — case-insensitive genre name. An unknown genre yields an empty pool.
- decade — the decade's first year, e.g. 1990 or 1990s.
- holiday — christmas, halloween, valentines_day, … (the error lists them).
- not_seen_days — exclude titles watched within this many days. **Defaults to
  90**, as on the page, so a fresh watch isn't offered again; pass "all" when
  the user explicitly wants a rewatch to be possible.
- added_days — include only titles added within this many days (a "something
  new" pick). Off by default.
- alternates — how many other eligible titles to return alongside the pick,
  0 to 9 (default 0). For "give me a few options".

The response is one "pick" — the spin result, chosen at random from the whole
matching pool, exactly as the page's reel lands on one — plus "alternates",
other titles from the same pool (the page shows these as the posters the reel
flips past). The pick is not chosen from the alternates; it is the answer, and
the alternates are runners-up in no particular order. "pool_size" is how many
titles matched; "pick" null with pool_size 0 means the filters matched nothing
(that's a 200, not an error — widen them). "filters" echoes what was applied.
A collection the user can't reach is 404.

   GET https://discs.fyi/api/spin?genre=Horror&not_seen_days=180&alternates=2
   Authorization: Bearer <token>

   {
     "pick": { "tmdb_id": 36647, "title": "Blade", "year": 1998, "type": "movie",
               "genres": ["Action", "Horror", "Thriller"], "formats": ["4K UHD"],
               "holiday": null, "collection_id": 12, "collection_name": "Movie Wall",
               "last_watched_at": "2026-02-11", "added_at": "2025-12-03" },
     "alternates": [
       { "tmdb_id": 694, "title": "The Shining", "year": 1980, "type": "movie", ... },
       { "tmdb_id": 9552, "title": "The Exorcist", "year": 1973, "type": "movie", ... }
     ],
     "pool_size": 14,
     "filters": { "collection": null, "type": null, "genre": "Horror", "decade": null,
                  "holiday": null, "not_seen_days": 180, "added_days": null }
   }

"last_watched_at" (null if never) and "added_at" let you say why it's a good
pick ("you haven't seen it since February"). Use "type" and "tmdb_id" with
/api/items/{tmdb_id} for the editions and full history.

## Writing — watches (scope: write)

### POST https://discs.fyi/api/releases/{release_id}/watch

Log that the user watched a release. Body fields, all optional:

- watched_at — YYYY-MM-DD. Send it. Defaults to today (UTC) if omitted.
- scope — "shared" (default) or "solo". A shared watch is visible to everyone
  in the collection the disc is in ("we watched it"); a solo watch is the
  user's alone. Use solo only when the user says so.
- collection_id — for a shared watch on a disc that sits in more than one
  collection, which one to attribute it to. Rarely needed.

   POST https://discs.fyi/api/releases/88/watch
   Authorization: Bearer <token>
   Content-Type: application/json

   { "watched_at": "2026-09-20" }

   201
   { "watch": { "id": 91, "release_id": 88, "watched_at": "2026-09-20", "scope": "shared", "collection_id": 12 } }

Errors: 400 `invalid_watched_at`, 400 `invalid_scope`, 404 `not_found`.

### PATCH https://discs.fyi/api/watches/{id}

Correct the date of a logged watch. "watched_at" is the only editable field —
to change which disc or who a watch belongs to, delete it and log a new one.

   PATCH https://discs.fyi/api/watches/91
   { "watched_at": "2026-09-19" }

   200 { "watch": { "id": 91, "release_id": 88, "watched_at": "2026-09-19", "scope": "shared", "collection_id": 12 } }

### DELETE https://discs.fyi/api/watches/{id}

Remove a logged watch. (This is a correction to history, so it needs only
`write`.)

   200 { "deleted": true, "id": 91 }

## Writing — releases (scope: write; DELETE needs delete)

### POST https://discs.fyi/api/releases

Catalogue a disc the user owns (or wants — see "status"). discs.fyi fetches the
title's metadata from TMDB, so you only supply the TMDB id and the disc's
details. Body:

- tmdb_id — required. The TMDB id of the movie, series or collection.
- type — "movie" (default), "tv" (a complete series), "tv-season" (one season;
  tmdb_id is then the SERIES id and "season_number" is required), or "box-set"
  (a TMDB movie collection).
- season_number — only with type tv-season.
- format — one of: "4K UHD", "Blu-ray", "Blu-ray 3D", "DVD", "HD DVD", "VHS",
  "LaserDisc", "Digital", "Other". Omit it (or send null) when the user didn't
  name one; don't guess. On a wishlist release no format means "any format
  will do"; on an owned disc it means the format is unknown.
- edition_type — one of: "Standard", "Director's Cut", "Extended", "Unrated",
  "Limited", "Exclusive", "Steelbook", "Digibook", "Criterion", "Anniversary",
  "Box Set", "Double Feature", "Triple Feature", "Other". Default "Standard".
- region — one of: "A", "B", "C", "ABC", "1", "2", "0", "Region Free". Omit if
  unknown.
- edition_notes, barcode, notes — free text.
- status — "owned" (default) or "wishlist". **To add a title to the user's
  wishlist, add a release with status "wishlist".**
- collection_id — which collection to file it in. Defaults to the user's main
  collection. shelf_id — optionally, a shelf of that collection.

   POST https://discs.fyi/api/releases
   { "tmdb_id": 36647, "format": "4K UHD", "edition_type": "Steelbook", "region": "A" }

   201 { "release": { "release_id": 92, "item_title": "Blade", "tmdb_id": 36647, ... } }

Errors: 400 `invalid_*` naming the field (with "valid" values), 404
`tmdb_not_found` (no such title on TMDB), 404 `collection_not_found` /
`shelf_not_found`, 400 `shelf_not_in_collection`, 409 `granularity_locked`
(the user already tracks this show at the other granularity — a series as a
whole vs. individual seasons — pick the same one), 400 `no_collection`.

Adding the same title twice creates a second release (a second copy or
edition); it is not a duplicate error. Check /api/items/{tmdb_id} first if the
user might already have it.

### PATCH https://discs.fyi/api/releases/{release_id}

Edit a release. Partial — send only what changes. Editable fields: format
(null clears it: any format on a wish, unknown on an owned disc),
edition_type, region, edition_notes, barcode, notes, status ("owned" or
"wishlist" — this is how a wishlist item becomes owned), shelf_id (null to
unshelve), collection_id (to move it to another collection the user belongs
to; the shelf is dropped unless a shelf of the destination is sent too).

   PATCH https://discs.fyi/api/releases/92
   { "shelf_id": 4 }

   200 { "release": { "release_id": 92, ..., "shelf_id": 4, "shelf_name": "Sci-Fi" } }

Move between shelves: send the new "shelf_id" (see /api/collections/{id}/shelves
for ids). Mark a wishlist item as bought: `{ "status": "owned" }` — and if the wish had
no format, send the format of the disc they actually got alongside it.

### DELETE https://discs.fyi/api/releases/{release_id} (scope: delete)

Remove a release from the user's collection, with its watch history. To remove
a *title* entirely, delete each of its releases (listed by
/api/items/{tmdb_id}). Confirm with the user before deleting.

   200 { "deleted": true, "id": 92, "tmdb_id": 36647 }

## Writing — shelves (scope: write; DELETE needs delete)

Shelves belong to a collection and are how the user physically organises discs.

### POST https://discs.fyi/api/collections/{collection_id}/shelves

   { "name": "Horror", "color": "#b91c1c" }      (color optional)

   201 { "shelf": { "id": 7, "collection_id": 12, "name": "Horror", "color": "#b91c1c" } }

### PATCH https://discs.fyi/api/shelves/{id}

Rename or recolour. Partial: `{ "name": "Horror & Thrillers" }`.

   200 { "shelf": { "id": 7, "collection_id": 12, "name": "Horror & Thrillers", "color": "#b91c1c" } }

### DELETE https://discs.fyi/api/shelves/{id} (scope: delete)

Deletes the shelf. Releases on it are unshelved, not deleted.

   200 { "deleted": true, "id": 7 }

## Worked examples

### "list my action movies from discs.fyi"

1. Run the device flow once and obtain a bearer token (reuse a saved one if you
   have it).
2. GET https://discs.fyi/api/movies?genre=Action with the Authorization header.
3. Present each movies[].title (with its year) back to the user.

If they then ask about one of those titles ("tell me about Blade"), take its
"tmdb_id" from the list and GET https://discs.fyi/api/items/<tmdb_id> — the response has
the editions they own and how many times they've watched it.

### "I watched Blade last night"

1. Make sure your token has `write` (check the saved scope; if not, re-run the
   device flow with `{ "scope": "read write" }`).
2. Find the release: GET https://discs.fyi/api/items?type=movie, match the title to get
   its tmdb_id, then GET https://discs.fyi/api/items/<tmdb_id> for its "releases". If
   they own more than one edition and didn't say which, ask, or pick the best
   format (4K UHD over Blu-ray over DVD).
3. Work out the date in the user's timezone (yesterday, as YYYY-MM-DD).
4. Glance at "watches.history" — if a watch on that date already exists, tell
   the user rather than logging a second one.
5. POST https://discs.fyi/api/releases/<release_id>/watch with { "watched_at": "<date>" }.
6. Confirm: "Logged Blade (4K UHD) as watched on 20 September."

### "pick something for movie night"

1. GET https://discs.fyi/api/spin — the defaults already skip anything watched in the
   last 90 days. Add filters from what the user said: "a horror movie" →
   ?type=movie&genre=Horror; "something from the 80s" → ?decade=1980;
   "something we haven't watched in ages" → ?not_seen_days=365; "one of the new
   ones" → ?added_days=30; "something Christmassy" → ?holiday=christmas.
2. If they name a collection ("from the kids' shelf"), find its id in
   /api/collections and pass ?collection=<id>.
3. Offer the "pick" with its year and why (last_watched_at / added_at). When
   they want a choice, add ?alternates=2 and present the pick first, then the
   alternates. If pool_size is 0, say the filters matched nothing and suggest
   loosening one.
4. If they watch it, log it: POST /api/releases/<release_id>/watch (get the
   release_id from /api/items/<tmdb_id>).

### "add the Dune steelbook to my wishlist"

1. Resolve the title to a TMDB id (search TMDB, or ask the user which Dune).
2. POST https://discs.fyi/api/releases with
   { "tmdb_id": 438631, "format": "4K UHD", "edition_type": "Steelbook", "status": "wishlist" }.
3. When they buy it: PATCH https://discs.fyi/api/releases/<release_id> with { "status": "owned" }.

### "add Dune to my wishlist"

No format named, so leave it out — the wish is for the film in any format:
POST https://discs.fyi/api/releases with { "tmdb_id": 438631, "status": "wishlist" }.
When they buy it, PATCH { "status": "owned", "format": "<the disc they got>" }.
