Library API
A GraphQL API for automating library management, separate from the player API that the Mydia Player uses.
Beta
This API is in beta. Breaking changes are allowed and are announced in the release notes. Once it is declared stable, changes become additive and a removal is deprecated for one minor release first.
Endpoint
Requests need Content-Type: application/json and an x-api-key header. The
api_key query parameter is not accepted: query strings reach proxy and access
logs, and this endpoint can change your library.
The examples on this page read the key from $LIBRARY_API_KEY in your shell; set
it to a database key's value or to the LIBRARY_API_KEY environment variable's
value, whichever kind you're using.
curl -s http://localhost:4000/api/library/graphql \
-H 'content-type: application/json' \
-H "x-api-key: $LIBRARY_API_KEY" \
-d '{"query": "{ qualityProfiles { id name } }"}'
Authentication
Two kinds of key work.
A database API key
Create one under Admin > System > API Keys. Give it a name, an expiry (never,
30, 90 or 365 days) and the Library API scope, which is stored as the admin
permission. The owner must be an admin. The plain key is shown once, when you
create it; copy it then. Revoke or delete a key from the same page.
Keys created through the player API's createApiKey mutation also work, as
long as they carry the admin permission.
LIBRARY_API_KEY
Set the environment variable and restart:
This key is not a database row, so it is never accepted by the player API and does not run an Argon2 verification on each request. Revoke it by removing the variable and restarting. A value shorter than 32 characters is refused at boot.
See Environment Variables for the full entry.
Queries
| Field | Returns |
|---|---|
lookup(query, type, year) |
Metadata-provider hits, each saying whether the library already has it |
mediaItem(id \| tmdbId \| tvdbId \| imdbId) |
One item with its availability status and episodes |
mediaItemChanges(first, after) |
A page of media item changes (live items and deletions), oldest revision first (see Polling for changes) |
downloads(filter) |
The download queue and history |
events(first, after, types) |
Library activity, oldest first (see Events) |
qualityProfiles |
Profiles available to assign |
libraryPaths |
Library paths available to add into |
Fetch the schema itself for the authoritative list:
curl -s http://localhost:4000/api/library/graphql \
-H 'content-type: application/json' \
-H "x-api-key: $LIBRARY_API_KEY" \
-d '{"query": "{ __schema { queryType { fields { name } } } }"}'
There is no GraphiQL or Playground page on this endpoint. Use any GraphQL client that can set a request header.
Polling for changes
mediaItemChanges is the convergence feed: it reports every change to a media
item, deletions included, in the order the changes were recorded. A poller keeps
one cursor and never needs a timestamp.
query Poll($after: String) {
mediaItemChanges(first: 50, after: $after) {
edges {
cursor
node {
mediaItemId
deleted
changedAt
mediaItem { id title updatedAt status { state } }
}
}
pageInfo { hasNextPage endCursor }
}
}
The first request omits after (or sends null). The feed then starts at the
oldest retained change and pages forward, so a full pass is an initial snapshot:
by the time hasNextPage is false you have seen the latest state of every item.
Keep endCursor and pass it as after on the next poll. An empty page repeats
the cursor you sent, so a poller that always passes back endCursor can never
fall back to the start of the feed.
Each edge is one change:
deleted: falseis a live item;mediaItemis the item's current representation. Upsert it into local state keyed bymediaItemId.deleted: trueis a tombstone:mediaItemisnullanddeletedis true, so delete the local item named bymediaItemId(or ignore the change if you never had it).mediaItemIdis present in both cases, which makes it the idempotency key and the deletion key: you never need to keep an earlier payload to act on a change.
{
"edges": [
{"cursor": "djE6NA", "node": {"mediaItemId": "…", "deleted": true, "changedAt": "…", "mediaItem": null}}
]
}
Drain a response before sleeping: while hasNextPage is true there is another
page waiting behind the same cursor, so call again with the new endCursor
instead of waiting for the next poll interval.
cursor = null
loop:
page = mediaItemChanges(first: 50, after: cursor) # omit after on the first call
apply every edge: upsert node.mediaItem when deleted is false, delete node.mediaItemId when true
cursor = page.pageInfo.endCursor
repeat immediately while page.pageInfo.hasNextPage, else wait for the next poll
What the contract guarantees:
- The cursor is the only synchronization watermark. It is opaque and derived
from an internal revision number, never from a timestamp. Do not parse it,
compare it to
changedAt, or build one yourself; passendCursorback verbatim. A malformed cursor, and any cursor minted by the retiredmediaItems(updatedSince:)feed, is refused withINVALID_INPUTinstead of being ignored, so a stale client resyncs from scratch rather than resuming somewhere arbitrary. - Repeated delivery is safe and expected. An item can appear on two pages, or
change again while you are reading it, so one
mediaItemIdmay arrive more than once, sometimes with the same state. Apply changes idempotently and treat omission (not repetition) as the only failure mode. - Tombstones are retained indefinitely. This grows by one row per media item ever created, not per change, so a consumer that stops polling for months can still resume from its last cursor and see every deletion it missed.
changedAtis informational. It is when the revision was recorded; for a live change it equals the node'smediaItem.updatedAt, which is the aggregate change time of the returned representation rather than only the parent row. Both are for display and for order-independent bookkeeping. Neither orders the feed and neither is a polling watermark. Note thatMediaItem.updatedAtnow reports the item's aggregate change time, which on upgrade is the migration instant for every existing item until it next changes.- Some availability changes have no database write behind them.
status.statecompares episode air dates with the current UTC date, so a show can leaveUPCOMINGat a UTC date boundary with nothing written to the database. A sweep marks the affected shows and they arrive as ordinary live changes, a little after the boundary rather than at it. Expect a show to appear with no child write, and re-read the whole selection from every live change instead of merging only the fields you believe changed. - There is no push channel. The endpoint answers requests only, so poll on the interval your consumers need; each page is a plain indexed read.
Mutations
| Field | Does |
|---|---|
addMovie(input: {tmdbId, qualityProfileId, libraryPathId, monitored, searchNow}) |
Adds a movie; searchNow queues an automatic search |
addTvShow(input: {tvdbId \| tmdbId, qualityProfileId, libraryPathId, monitored, seasonMonitoring, searchNow}) |
Adds a show; tvdbId wins when both ids are given |
removeMediaItem(input: {id, deleteFiles}) |
Removes an item, and its files when deleteFiles is true. filesNotDeleted counts files that could not be removed from disk |
setMediaItemMonitored(id, monitored) |
Monitoring for a movie or show |
setSeasonMonitored(mediaItemId, season, monitored) |
Monitoring for every episode of a season |
setEpisodeMonitored(id, monitored) |
Monitoring for one episode |
applyEpisodeMonitoring(mediaItemId, preset) |
ALL, MISSING, EXISTING, FUTURE or NONE |
searchMediaItem(id) |
Queues an automatic search for a movie or a whole show |
searchSeason(mediaItemId, season) |
Queues a search for one season, preferring a season pack |
searchEpisode(id) |
Queues a search for one episode |
cancelDownload(id) |
Removes a download from its client and the queue |
rejectRelease(id, blocklistDays) |
Removes a download, blocklists its release and searches again |
Each returns a payload with the thing it changed and a userErrors list. An
expected failure leaves the main field null and fills userErrors, each with a
code, a message, and the field path of the argument that caused it:
{
"data": {
"addMovie": {
"mediaItem": null,
"userErrors": [
{"field": ["input", "qualityProfileId"], "code": "INVALID_INPUT", "message": "Not a valid id"}
]
}
}
}
| Code | Meaning |
|---|---|
ALREADY_IN_LIBRARY |
The title is already there; mediaItem is the existing item |
NOT_FOUND |
An id names nothing |
INVALID_INPUT |
The arguments cannot be satisfied |
METADATA_UNAVAILABLE |
The metadata relay could not provide the title |
CLIENT_UNAVAILABLE |
cancelDownload only: the download client could not remove it, so the download is still queued |
searchMediaItem, searchSeason and searchEpisode report queued: true when
the job is inserted. A repeat within 60 seconds is merged into the first and
still reports true. cancelDownload and rejectRelease return removedId,
because both delete the download row. rejectRelease removes the item from its
client on a best-effort basis, as the Downloads page does.
Events
events pages through library activity, oldest first:
{ events(first: 100, after: "…") { edges { node { type occurredAt data } } pageInfo { endCursor } } }
Keep endCursor and pass it as after next time. On an empty page endCursor
repeats the after you sent, so you can always pass it back. types narrows
the feed to the types you name; by default you get every published type, and a
type outside that list is INVALID_INPUT. data's keys depend on the type and
may change during beta.
The feed is best-effort activity history, not a convergence feed. Use it to learn
that something happened, then read mediaItemChanges or downloads for the
state. If what you need is the current state of the library, poll
mediaItemChanges and skip events entirely; the two use different cursors and
are not interchangeable.
- Events can be missing. Mydia drops events under heavy load and loses a batch whose write fails.
- An event appears about 35 seconds after it happens, a delay longer than the database's own write timeout, so an event written during a library scan still lands before the feed hands out a cursor past it.
- Events are kept for 90 days. A cursor older than that resumes at the oldest event left.
Cost
downloadscontacts every configured download client on each call, so it is not paged and is the most expensive query here. For polling a library's changes prefermediaItemChanges: it is a plain indexed read and contacts nothing.mediaItemChangesprices a page asfirsttimes the cost of the selection underedges { node { … } }, and the endpoint's complexity budget is 2000. What you select insidenodetherefore decides how large a page you can take. A page ofmediaItem { id title updatedAt status { state } }costs 2200 atfirst: 200and is refused; the same selection costs 550 at the defaultfirst: 50and passes. Narrowing buys the page size back:mediaItem { id }costs 1400 atfirst: 200, and asking only for the change itself (mediaItemId,deleted,changedAt) costs 1000. So keep the defaultfirst: 50, lowerfirst, or select fewermediaItemfields when you need a larger page.mediaItemChangestakesfirstfrom 1 to 200 (default 50); a value outside that range is rejected withINVALID_INPUTrather than clamped to it. The complexity refusal is a separate GraphQL error: it names complexity rather thanINVALID_INPUT, and it arrives before any query runs.- A page holds one entry per changed item, so one poll page is one read however many items it carries; the cost is the price of the selected fields, not of the library.
addMovieandaddTvShoweach ask the metadata relay for the title, and are priced likelookup.eventstakesfirstfrom 1 to 200 (default 100), priced likemediaItemChanges.
Errors
Queries report expected failures as GraphQL errors carrying a code in
extensions.code (mutations use userErrors, above):
| Code | Meaning |
|---|---|
FORBIDDEN |
The key is valid but its role may not perform this operation |
INVALID_INPUT |
The arguments cannot be satisfied, e.g. two identifiers, or a broken cursor |
METADATA_UNAVAILABLE |
The metadata relay could not be reached |
HTTP-level failures are 401 for a missing or invalid key, 403 for a key without
the admin scope, and 429 when too many failed attempts come from one address.
A key also authenticates on the player API
A database API key authenticates as its owner everywhere that owner can go, including the player API. There, an API-key caller can create further keys and pair devices, and those credentials keep working after the original key is revoked. Treat a library API key like the owner's password.
LIBRARY_API_KEY does not have this problem: it is not a database row, so the
player API never accepts it.