// The API reference's content: each service's endpoints, grouped, and the types they take and // give, as the handlers in crates/ipx/src/web.rs and crates/directory/src/lib.rs have them. A // change to a route or a field there wants its line here; gen.mjs turns this into pages. // Types are written as Rust writes them, the way docs.rs shows a signature; a name in TYPES links // to its page. const U = 'http://:8099'; const D = 'https://directory.ipxreader.app'; const ipxCurl = (m, p, body) => `curl${m === 'GET' ? '' : ` -X ${m}`} -H "Authorization: Bearer ipx_…"${body ? ` \\\n -H "Content-Type: application/json" -d '${body}'` : ''} \\\n "${U}${p}"`; const dirCurl = (m, p, body) => `curl${m === 'GET' ? '' : ` -X ${m}`} -H "Authorization: Bearer "${body ? ` \\\n -H "Content-Type: application/json" -d '${body}'` : ''} \\\n "${D}${p}"`; const NOT_SUBSCRIBED = ['404 Not Found', 'You do not subscribe to the feed, or it does not exist. A feed someone else reads is not yours to see.']; const NOT_ADMIN = ['403 Forbidden', 'You are not an admin.']; const NO_DIRECTORY = ['503 Service Unavailable', 'No directory is set up on this server.']; export const ipx = { name: 'ipx', root: '/docs/api/ipx', changelog: 'crates/ipx/CHANGELOG.md', intro: `

The HTTP API of an iPX server: everything the page does, a script can do. Every request and answer is JSON unless an item says otherwise. The page is served by ipx-web, which passes /api and /media on to ipx at the same address, so a script talks to the address people open in a browser.

Signing in

Send an API token, made in Settings under API tokens or with POST /api/tokens:

Authorization: Bearer ipx_…

A token acts as the person who made it, and is an admin only if they are. A browser signs in with POST /api/login instead and is given a cookie. Behind Cloudflare Access, Access stops a request with no Access sign-in before iPX sees it: send an Access service token's CF-Access-Client-Id and CF-Access-Client-Secret beside the API token.

Errors

A request that fails answers with its status and a sentence saying why, as plain text: 401 not signed in, 403 an admin's to do, 404 not there or not yours, 400 a request that cannot be done as asked, 409 a conflict the answer explains.

Ids

A feed's id is a short name made from its title, such as 99-invisible, and appears in the page's address when the feed is open. An episode is its feed's id and its guid, the guid URL-encoded in a path. A file (an enclosure) has a number. Times are Unix seconds.

`, groups: [ { id: 'auth', name: 'auth', title: 'Signing in and tokens', summary: 'Who you are, signing in and out, and API tokens.', items: [ { method: 'POST', path: '/api/login', slug: 'post-login', access: 'none', summary: 'Sign in with a name and password, for a session cookie.', doc: '

Answers who signed in and sets the session cookie, which lasts session_days from the last request. A script is better off with an API token, which needs no password kept.

', body: 'Credentials', returns: 'Me', errors: [['401 Unauthorized', 'The name or password is wrong.']], example: `curl -c cookies -H "Content-Type: application/json" \\\n -d '{"name":"sam","password":"…"}' "${U}/api/login"`, }, { method: 'POST', path: '/api/logout', slug: 'post-logout', summary: 'Sign out: end the session and clear its cookies.', returns: '204 No Content', }, { method: 'GET', path: '/api/me', slug: 'get-me', summary: 'Who you are, your words that hide items, and the server’s version.', returns: 'Me', example: ipxCurl('GET', '/api/me'), }, { method: 'PATCH', path: '/api/me', slug: 'patch-me', summary: 'Change the words that hide an item in every feed you read.', body: 'MePatch', returns: '204 No Content', errors: [['400 Bad Request', 'More than 500 words, or one over 200 characters.']], example: ipxCurl('PATCH', '/api/me', '{"blocked":["sponsor","ad read"]}'), }, { method: 'GET', path: '/api/tokens', slug: 'get-tokens', summary: 'Your API tokens: names and when each was made and last used, never the token.', returns: 'Vec', }, { method: 'POST', path: '/api/tokens', slug: 'post-tokens', summary: 'Make an API token that acts as you.', doc: '

The answer is the only time the token is shown: only its SHA-256 is kept.

', body: 'NewToken', returns: 'NewTokenMade', errors: [['400 Bad Request', 'No name.']], example: ipxCurl('POST', '/api/tokens', '{"name":"my script"}'), }, { method: 'DELETE', path: '/api/tokens/{id}', slug: 'delete-token', summary: 'Revoke one of your API tokens.', pathParams: [['id', 'i64', 'The token’s id, from GET /api/tokens.']], returns: '204 No Content', errors: [['404 Not Found', 'No token of yours has that id.']], }, ], }, { id: 'feeds', name: 'feeds', title: 'Feeds', summary: 'Your subscriptions: list, subscribe, change, unsubscribe, check, OPML.', items: [ { method: 'GET', path: '/api/feeds', slug: 'get-feeds', summary: 'Your subscriptions, each with its counts and your settings for it.', returns: 'Vec', example: ipxCurl('GET', '/api/feeds'), }, { method: 'POST', path: '/api/feeds', slug: 'post-feeds', summary: 'Subscribe to a feed, by its address or a site that links one.', doc: '

A feed someone else on the server already has costs nothing to add: it is fetched once and each file is kept once. An OPML address subscribes to the list, kept in step as a folder.

', body: 'NewFeed', returns: 'Added', errors: [['400 Bad Request', 'The address is not a feed and links none; the answer says what was found.']], example: ipxCurl('POST', '/api/feeds', '{"url":"https://feeds.99percentinvisible.org/99percentinvisible"}'), }, { method: 'PATCH', path: '/api/feeds/{id}', slug: 'patch-feed', summary: 'Change your settings for a feed, or (an admin) the feed’s own.', doc: '

Send only the fields to change. Which episodes you want, whether to download them, how many at a time, DeadAir and pinning are yours, and change for nobody else. The feed’s address, folder, schedule and category, and marking it shareable again, are the same for everyone and an admin’s to change.

', pathParams: [['id', 'String', 'The feed’s id.']], body: 'FeedPatch', returns: '204 No Content', errors: [['400 Bad Request', 'A value out of range, such as a DeadAir pause that is not 200, 300, 400 or 500.'], ['403 Forbidden', 'A field only an admin may change.'], NOT_SUBSCRIBED], example: ipxCurl('PATCH', '/api/feeds/99-invisible', '{"keywords":["mini-stories"],"pinned":true}'), }, { method: 'DELETE', path: '/api/feeds/{id}', slug: 'delete-feed', summary: 'Unsubscribe. The files stay for anyone else who reads the feed.', pathParams: [['id', 'String', 'The feed’s id.']], returns: '204 No Content', errors: [NOT_SUBSCRIBED], }, { method: 'POST', path: '/api/feeds/{id}/read-all', slug: 'post-read-all', summary: 'Mark every episode of a feed read, and of every feed in it for an OPML list.', pathParams: [['id', 'String', 'The feed’s id.']], returns: 'Marked', }, { method: 'POST', path: '/api/feeds/{id}/download-latest', slug: 'post-download-latest', summary: 'Download a feed’s newest episodes that are not downloaded yet.', pathParams: [['id', 'String', 'The feed’s id.']], body: 'HowMany', returns: 'Queued', errors: [NOT_SUBSCRIBED], example: ipxCurl('POST', '/api/feeds/99-invisible/download-latest', '{"count":3}'), }, { method: 'POST', path: '/api/fetch', slug: 'post-fetch', summary: 'Check one feed, or all of yours, for new episodes now.', doc: '

The check runs in the background; GET /api/events shows it as it goes.

', body: 'FetchBody', returns: '202 Accepted', example: ipxCurl('POST', '/api/fetch', '{"feed":"99-invisible","force":true}'), }, { method: 'GET', path: '/api/opml', slug: 'get-opml', summary: 'Your subscriptions as an OPML file, for another podcast app.', returns: 'OPML (text/x-opml)', }, { method: 'POST', path: '/api/opml', slug: 'post-opml', summary: 'Subscribe to every feed in an OPML file.', body: 'OpmlBody', returns: 'Imported', errors: [['400 Bad Request', 'Not OPML.']], }, ], }, { id: 'entries', name: 'entries', title: 'Episodes', summary: 'Episodes and posts: list, search, mark read or kept, save where you are.', items: [ { method: 'GET', path: '/api/feeds/{id}/entries', slug: 'get-feed-entries', summary: 'A page of one feed’s episodes, newest first unless sorted.', pathParams: [['id', 'String', 'The feed’s id.']], query: 'Page', returns: 'EntryPage', errors: [NOT_SUBSCRIBED], example: ipxCurl('GET', '/api/feeds/99-invisible/entries?filter=unread&limit=10'), }, { method: 'GET', path: '/api/entries', slug: 'get-entries', summary: 'The same, across every feed you subscribe to.', query: 'Page', returns: 'EntryPage', example: ipxCurl('GET', '/api/entries?q=bridges&sort=published&dir=desc'), }, { method: 'GET', path: '/api/entries/{feed}/{guid}', slug: 'get-entry', summary: 'One episode.', pathParams: [['feed', 'String', 'The feed’s id.'], ['guid', 'String', 'The episode’s guid, URL-encoded.']], returns: 'EntryRow', errors: [NOT_SUBSCRIBED], }, { method: 'POST', path: '/api/entries/{feed}/{guid}/flags', slug: 'post-flags', summary: 'Mark an episode read or unread, kept or not.', doc: '

Answers with the feed’s row, its counts changed, so a list can redraw one line.

', pathParams: [['feed', 'String', 'The feed’s id.'], ['guid', 'String', 'The episode’s guid, URL-encoded.']], body: 'Flags', returns: 'Option', errors: [NOT_SUBSCRIBED], example: ipxCurl('POST', '/api/entries/99-invisible/{guid}/flags', '{"read":true}'), }, { method: 'POST', path: '/api/entries/{feed}/{guid}/position', slug: 'post-position', summary: 'Save where you are in an episode, for every device you listen on.', pathParams: [['feed', 'String', 'The feed’s id.'], ['guid', 'String', 'The episode’s guid, URL-encoded.']], body: 'Position', returns: '204 No Content', errors: [NOT_SUBSCRIBED], example: ipxCurl('POST', '/api/entries/99-invisible/{guid}/position', '{"secs":1234}'), }, { method: 'POST', path: '/api/read-all', slug: 'post-read-all-mine', summary: 'Mark every episode of every feed you subscribe to read.', returns: 'Marked', }, ], }, { id: 'files', name: 'files', title: 'Files', summary: 'Downloading, deleting and playing an episode’s file.', items: [ { method: 'POST', path: '/api/enclosures/{id}/download', slug: 'post-download', summary: 'Download a file now.', pathParams: [['id', 'i64', 'The file’s id, an EncRow’s id.']], returns: '202 Accepted, or 204 No Content when it is already here', errors: [NOT_SUBSCRIBED], }, { method: 'DELETE', path: '/api/enclosures/{id}', slug: 'delete-file', summary: 'Delete a downloaded file. It is not downloaded again.', doc: '

There is one copy of each file, so deleting it deletes it for everyone. If someone else has kept the episode or not played it yet, the first try is refused with who: send it again with ?force=true to delete it anyway.

', pathParams: [['id', 'i64', 'The file’s id.']], query: 'Force', returns: '204 No Content', errors: [['409 Conflict', 'Someone else still wants it; the answer says who. Send force=true to go ahead.'], NOT_SUBSCRIBED], }, { method: 'GET', path: '/media/{id}', slug: 'get-media', summary: 'The file itself, with Range requests, so a player can seek.', pathParams: [['id', 'i64', 'The file’s id.']], returns: 'The file, with its own Content-Type', errors: [NOT_SUBSCRIBED], }, { method: 'GET', path: '/media/{id}/deadair.m3u8', slug: 'get-deadair', summary: 'The episode with its long silences shortened by DeadAir, as an HLS playlist.', doc: '

Only once DeadAir has looked at the file: an EncRow with deadair set says how many seconds it saves. /media/{id}/deadair gives the shortened episode as one file to save.

', pathParams: [['id', 'i64', 'The file’s id.']], returns: 'An HLS playlist (application/vnd.apple.mpegurl)', errors: [NOT_SUBSCRIBED], }, ], }, { id: 'directory', name: 'directory', title: 'The directory, through iPX', summary: 'Browse the directory and subscribe from it; iPX asks it on your behalf.', items: [ { method: 'GET', path: '/api/popular', slug: 'get-popular', summary: 'The directory’s ten most subscribed feeds.', returns: 'Vec', errors: [NO_DIRECTORY], }, { method: 'GET', path: '/api/directory', slug: 'get-directory', summary: 'Every feed the directory lists, A to Z.', returns: 'Vec', errors: [NO_DIRECTORY], }, { method: 'GET', path: '/api/directory/{id}', slug: 'get-listed', summary: 'A feed’s page in the directory: what it says it is and its latest items.', pathParams: [['id', 'String', 'The directory’s id for the feed.']], returns: 'Listed', errors: [['404 Not Found', 'Not in the directory.'], NO_DIRECTORY], }, { method: 'POST', path: '/api/popular/{id}', slug: 'post-popular', summary: 'Subscribe to a feed from the directory, by the directory’s id.', pathParams: [['id', 'String', 'The directory’s id for the feed.']], returns: 'Subscribed', errors: [['400 Bad Request', 'Not in the directory.'], NO_DIRECTORY], }, ], }, { id: 'server', name: 'server', title: 'The server', summary: 'Its counts and version, and live updates.', items: [ { method: 'GET', path: '/api/status', slug: 'get-status', summary: 'How many feeds, files waiting and files downloaded, and the version.', doc: '

The numbers ipx status prints, for a dashboard.

', returns: 'Status', example: ipxCurl('GET', '/api/status'), }, { method: 'GET', path: '/api/events', slug: 'get-events', summary: 'A live stream of checks and downloads as they happen (server-sent events).', doc: '

Each message’s data is one JSON Event. Every event of a check reaches everyone; a feed’s new row (feed_row) only those who subscribe to it.

', returns: 'text/event-stream of Event', example: `curl -N -H "Authorization: Bearer ipx_…" "${U}/api/events"\ndata: {"ev":"feed_start","feed":"99-invisible"}\ndata: {"ev":"feed_done","feed":"99-invisible","new":1,"downloaded":1,"failed":0,"torrents":0}`, }, ], }, { id: 'admin', name: 'admin', title: 'Admin', summary: 'Server settings, accounts, the log and the directory’s administration. Admins only.', items: [ { method: 'GET', path: '/api/settings', slug: 'get-settings', access: 'signed in', summary: 'The server’s settings. Anyone signed in may read them.', returns: 'Settings', }, { method: 'PATCH', path: '/api/settings', slug: 'patch-settings', access: 'admin', summary: 'Change the server’s settings. They take effect at once.', body: 'SettingsPatch', returns: '204 No Content', errors: [['400 Bad Request', 'A value that does not parse, such as a schedule.'], NOT_ADMIN], example: ipxCurl('PATCH', '/api/settings', '{"schedule":"every 2h","max_total_gb":200}'), }, { method: 'GET', path: '/api/users', slug: 'get-users', access: 'admin', summary: 'Every account.', returns: 'Vec', errors: [NOT_ADMIN], }, { method: 'POST', path: '/api/users', slug: 'post-users', access: 'admin', summary: 'Add an account.', doc: '

With no password, the account signs in only through single sign-on.

', body: 'NewUser', returns: '201 Created', errors: [['400 Bad Request', 'No name, or one that exists already.'], NOT_ADMIN], example: ipxCurl('POST', '/api/users', '{"name":"sam","password":"…"}'), }, { method: 'PATCH', path: '/api/users/{id}', slug: 'patch-user', access: 'admin', summary: 'Make an account an admin, or not.', pathParams: [['id', 'i64', 'The account’s id.']], body: 'UserPatch', returns: '204 No Content', errors: [['400 Bad Request', 'No such account, or the only admin, who cannot stop being one.'], NOT_ADMIN], }, { method: 'DELETE', path: '/api/users/{id}', slug: 'delete-user', access: 'admin', summary: 'Remove an account, with its subscriptions and read state. Files stay.', pathParams: [['id', 'i64', 'The account’s id.']], returns: '204 No Content', errors: [['400 Bad Request', 'No such account, or the only admin, who cannot be removed.'], NOT_ADMIN], }, { method: 'GET', path: '/api/logs', slug: 'get-logs', access: 'admin', summary: 'The newest lines of the server’s log, or those after a given one.', query: 'LogQuery', returns: 'LogPage', errors: [NOT_ADMIN], }, { method: 'GET', path: '/api/directory-status', slug: 'get-directory-status', access: 'admin', summary: 'Who the directory takes this server’s token to be, and how the last share went.', returns: 'DirectoryStatus', errors: [NOT_ADMIN], }, { method: 'DELETE', path: '/api/directory/{id}', slug: 'delete-listed', access: 'admin', summary: 'Remove a feed from the directory and keep it out.', doc: '

Only when this server’s directory token is rwa; the directory refuses otherwise.

', pathParams: [['id', 'String', 'The directory’s id for the feed.']], returns: '204 No Content', errors: [NOT_ADMIN, NO_DIRECTORY], }, { method: 'POST', path: '/api/directory/{id}/refresh', slug: 'post-listed-refresh', access: 'admin', summary: 'Have the directory check a feed now. Needs an rwa token.', pathParams: [['id', 'String', 'The directory’s id for the feed.']], returns: '204 No Content', errors: [NOT_ADMIN, NO_DIRECTORY], }, { method: 'GET', path: '/api/directory-admin', slug: 'get-directory-admin', access: 'admin', summary: 'The servers sharing with the directory, and the feeds it keeps out. Needs rwa.', returns: 'DirectoryAdmin', errors: [NOT_ADMIN, NO_DIRECTORY], }, { method: 'DELETE', path: '/api/directory-blocked/{id}', slug: 'delete-directory-blocked', access: 'admin', summary: 'Let a feed the directory keeps out back in. Needs rwa.', pathParams: [['id', 'String', 'The directory’s id for the feed.']], returns: '204 No Content', errors: [NOT_ADMIN, NO_DIRECTORY], }, { method: 'DELETE', path: '/api/directory-instances/{name}', slug: 'delete-directory-instance', access: 'admin', summary: 'Drop a server’s share from the directory until it next shares. Needs rwa.', pathParams: [['name', 'String', 'The server’s token name.']], returns: '204 No Content', errors: [NOT_ADMIN, NO_DIRECTORY], }, ], }, ], types: [ { name: 'Credentials', doc: 'A name and password.', fields: [['name', 'String', ''], ['password', 'String', '']] }, { name: 'Me', doc: 'Who is signed in.', fields: [ ['name', 'String', 'The account’s name, often an email address.'], ['admin', 'bool', ''], ['blocked', 'Vec', 'Words that hide an item in every feed you read. GET /api/me only, as are the fields below.'], ['sign_out', 'Option', 'Where signing out should go, for someone single sign-on signed in.'], ['deadair_saved', 'i64', 'Seconds DeadAir has saved you, all told.'], ['version', 'String', 'ipx’s version.'], ] }, { name: 'MePatch', doc: 'What PATCH /api/me changes.', fields: [['blocked', 'Option>', 'Replaces the list.']] }, { name: 'Token', doc: 'An API token, as listed.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['created', 'i64', ''], ['last_used', 'Option', '']] }, { name: 'NewToken', doc: 'A token to make.', fields: [['name', 'String', 'What it is for, to know it by when revoking it.']] }, { name: 'NewTokenMade', doc: 'A token just made: the only time it is shown.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['token', 'String', 'Begins ipx_. Send it as Authorization: Bearer.']] }, { name: 'FeedRow', doc: 'A feed as you see it: the feed, your settings for it and its counts for you.', fields: [ ['id', 'String', ''], ['url', 'String', ''], ['title', 'Option', ''], ['image', 'Option', ''], ['folder', 'Option', ''], ['category', 'Option', 'The directory category an admin gave it.'], ['feed_category', 'Option', 'The one the feed names itself, which wins.'], ['keywords', 'Vec', 'Your words: with any, only episodes that mention one are downloaded.'], ['blocked', 'Vec', 'Your words that hide an episode of this feed.'], ['deadair', 'bool', 'Played with its silences shortened.'], ['deadair_pause', 'i64', 'The pause DeadAir leaves where it cuts, in milliseconds.'], ['allow_explicit', 'bool', ''], ['auto_download', 'bool', ''], ['max_new_per_check', 'Option', 'Your own limit, or none for the server’s.'], ['group', 'Option', 'The OPML list it came from.'], ['orphaned', 'bool', 'In a list that no longer has it, kept for its downloads.'], ['managed', 'bool', ''], ['schedule', 'Option', 'Its own schedule, such as every 2h.'], ['schedule_mins', 'Option', 'The same in minutes.'], ['every_mins', 'u64', 'How often it is checked, all things considered.'], ['last_checked', 'Option', ''], ['next_check', 'Option', ''], ['last_error', 'Option', ''], ['failing', 'Option', 'Set once an error has held for a day.'], ['entries', 'i64', ''], ['downloaded', 'i64', ''], ['unread', 'i64', ''], ['subscribers', 'i64', 'People on this server, you included.'], ['pinned', 'bool', 'At the top of your list.'], ['private', 'bool', 'Kept out of the directory.'], ['private_by_address', 'bool', 'Private whatever it is marked: a login, or a key in its address.'], ] }, { name: 'FailingRow', doc: 'Why a feed has been failing.', fields: [['reason', 'String', 'In words.'], ['new_url', 'Option', 'Where it seems to have moved.']] }, { name: 'NewFeed', doc: 'A feed to subscribe to.', fields: [ ['url', 'String', 'A feed’s address, an OPML list’s, or a site’s page that links its feed.'], ['folder', 'Option', ''], ['keywords', 'Vec', 'Download only episodes that mention one. Empty for all.'], ['allow_explicit', 'bool', 'Download explicit episodes too. False if left out.'], ] }, { name: 'Added', doc: 'A feed subscribed to.', fields: [['id', 'String', 'Its id here.'], ['existing', 'bool', 'You subscribed to it already.']] }, { name: 'FeedPatch', doc: 'What PATCH /api/feeds/{id} changes. Leave out what stays; null clears a field that can be cleared.', fields: [ ['keywords', 'Option>', 'Yours.'], ['blocked', 'Option>', 'Yours.'], ['allow_explicit', 'Option', 'Yours.'], ['auto_download', 'Option', 'Yours.'], ['max_new_per_check', 'Option>', 'Yours; null for the server’s.'], ['pinned', 'Option', 'Yours.'], ['deadair', 'Option>', 'Yours; null follows its OPML list’s.'], ['deadair_pause', 'Option', 'Yours: 200, 300, 400 or 500.'], ['private', 'Option', 'Anyone subscribed may mark it private; only an admin unmarks it.'], ['url', 'Option', 'Admin.'], ['schedule', 'Option>', 'Admin.'], ['folder', 'Option>', 'Admin.'], ['category', 'Option>', 'Admin.'], ] }, { name: 'Marked', doc: '', fields: [['marked', 'i64', 'How many episodes were marked.']] }, { name: 'HowMany', doc: '', fields: [['count', 'i64', 'From 1 to 100; 5 if left out.']] }, { name: 'Queued', doc: '', fields: [['queued', 'usize', 'How many files were queued.']] }, { name: 'FetchBody', doc: 'What to check.', fields: [['feed', 'Option', 'One feed; all of yours if left out.'], ['force', 'bool', 'Check even if it is not due.']] }, { name: 'OpmlBody', doc: '', fields: [['xml', 'String', 'The OPML file’s text.']] }, { name: 'Imported', doc: '', fields: [['added', 'usize', ''], ['already', 'usize', 'Feeds you had.']] }, { name: 'Page', doc: 'Which episodes, as query parameters.', fields: [ ['offset', 'i64', '0 if left out.'], ['limit', 'i64', '50 if left out.'], ['filter', 'Option', 'unread, downloaded or flagged (kept).'], ['q', 'Option', 'Words to look for in titles and show notes.'], ['sort', 'Option', 'published, title, feed, type, size or kept. Newest first if left out.'], ['dir', 'Option', 'asc or desc.'], ] }, { name: 'EntryPage', doc: 'A page of episodes.', fields: [['total', 'i64', 'How many match, over every page.'], ['entries', 'Vec', '']] }, { name: 'EntryRow', doc: 'An episode or post, with your state for it.', fields: [ ['guid', 'String', ''], ['feed_id', 'String', ''], ['title', 'Option', ''], ['link', 'Option', ''], ['published', 'Option', ''], ['description', 'Option', 'Show notes, HTML, already sanitized.'], ['read', 'bool', ''], ['flagged', 'bool', 'Kept: its file is never cleared to save space.'], ['image', 'Option', ''], ['duration', 'Option', 'Seconds.'], ['episode', 'Option', ''], ['season', 'Option', ''], ['position', 'i64', 'Where you are in it, in seconds.'], ['enclosures', 'Vec', 'Its files.'], ] }, { name: 'EncRow', doc: 'An episode’s file.', fields: [ ['id', 'i64', 'What /media/{id} and the file routes take.'], ['feed_id', 'String', ''], ['guid', 'String', ''], ['url', 'String', 'Where the publisher has it.'], ['mime', 'Option', ''], ['length', 'Option', 'Bytes.'], ['path', 'Option', 'Where it is on the server, once downloaded.'], ['state', 'String', 'pending (a check will download it), held (back catalogue, downloaded on request), downloading, done, skipped (a filter or a failure) or reaped (deleted).'], ['last_error', 'Option', ''], ['deadair', 'Option', 'Seconds DeadAir saves, when you play it shortened. Absent otherwise.'], ['deadair_pending', 'bool', 'DeadAir is still working on it. Absent when false.'], ] }, { name: 'Flags', doc: '', fields: [['read', 'Option', ''], ['flagged', 'Option', 'Kept.']] }, { name: 'Position', doc: '', fields: [['secs', 'i64', 'Seconds in.'], ['duration', 'Option', 'The length the player measured, for an episode whose feed gives none.']] }, { name: 'Force', doc: 'A query parameter.', fields: [['force', 'bool', 'Delete it even though someone else wants it.']] }, { name: 'PopularRow', doc: 'A feed in the directory. Never its address: subscribe by its id.', fields: [ ['id', 'String', 'The directory’s id for it.'], ['title', 'Option', ''], ['image', 'Option', ''], ['subscribers', 'i64', 'On every server sharing with the directory.'], ['subscribed', 'bool', 'Yours already.'], ['feed', 'Option', 'Its id here, when it is yours.'], ['category', 'Option', ''], ['subcategory', 'Option', ''], ['podcast', 'bool', 'Has audio or video.'], ] }, { name: 'Listed', doc: 'A feed’s page in the directory.', fields: [ ['description', 'Option', 'What it says it is, sanitized.'], ['failing', 'Option', 'Why its last check failed, when it did.'], ['items', 'Vec', 'Its latest twenty.'], ['can_admin', 'bool', 'You may remove or refresh it.'], ] }, { name: 'ListedItem', doc: '', fields: [['guid', 'String', ''], ['title', 'Option', ''], ['link', 'Option', ''], ['published', 'Option', ''], ['duration', 'Option', ''], ['description', 'Option', 'Sanitized.']] }, { name: 'Subscribed', doc: '', fields: [['id', 'String', 'The feed’s id here.']] }, { name: 'Status', doc: '', fields: [['feeds', 'usize', ''], ['pending', 'usize', 'Files a check will download.'], ['downloaded', 'usize', ''], ['version', 'String', '']] }, { name: 'Event', doc: 'One live update. ev says which, and the rest depends on it.', fields: [ ['feed_start', '{ feed }', 'A feed is being checked.'], ['feed_skip', '{ feed }', 'Not due.'], ['feed_done', '{ feed, new, downloaded, failed, torrents }', ''], ['feed_error', '{ feed, msg }', ''], ['feed_row', '{ row: FeedRow }', 'A feed you subscribe to changed.'], ['progress', '{ feed, enclosure, file, done, total }', 'Bytes of a download, in whole percents.'], ['download_done', '{ feed, enclosure, path, bytes }', ''], ['download_error', '{ feed, enclosure, msg, url }', ''], ['deadair_done', '{ feed, enclosure, saved_s }', 'DeadAir finished a file.'], ['scan_done', '{ feeds }', 'A check finished; how many feeds it looked at.'], ] }, { name: 'Settings', doc: 'The server’s settings.', fields: [ ['schedule', 'String', 'How often feeds are checked, such as every 60m.'], ['every_mins', 'u64', 'The same in minutes.'], ['max_new_per_check', 'usize', 'The newest episodes a check downloads, a feed.'], ['media_types', 'Vec', 'What is downloaded, such as audio, video.'], ['download_dir', 'String', ''], ['max_total_gb', 'f64', 'Disk to use; 0 for no limit.'], ['max_age_days', 'u64', '0 for no limit.'], ['art_cache_mb', 'u64', 'Artwork kept.'], ['directory_url', 'String', ''], ['directory_token_set', 'bool', 'The token itself is never sent back.'], ['directory_share', 'bool', ''], ] }, { name: 'SettingsPatch', doc: 'What PATCH /api/settings changes. Leave out what stays.', fields: [ ['schedule', 'Option', ''], ['max_new_per_check', 'Option', ''], ['media_types', 'Option>', ''], ['max_total_gb', 'Option', ''], ['max_age_days', 'Option', ''], ['art_cache_mb', 'Option', ''], ['directory_url', 'Option', 'Empty for no directory.'], ['directory_token', 'Option', 'Replaces it; empty clears it.'], ['directory_share', 'Option', ''], ] }, { name: 'UserRow', doc: 'An account.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['admin', 'bool', ''], ['password', 'bool', 'Has one; without, it signs in only by single sign-on.'], ['created', 'Option', ''], ['last_login', 'Option', '']] }, { name: 'NewUser', doc: '', fields: [['name', 'String', 'No commas, semicolons or line breaks.'], ['password', 'String', 'Empty for single sign-on only.'], ['admin', 'bool', '']] }, { name: 'UserPatch', doc: '', fields: [['admin', 'bool', '']] }, { name: 'LogQuery', doc: 'Query parameters.', fields: [['after', 'u64', 'The last seq you have; 0 for the newest lines.'], ['limit', 'usize', '200 if left out.']] }, { name: 'LogPage', doc: '', fields: [['lines', 'Vec', ''], ['latest', 'u64', 'The newest seq, to ask after next time.']] }, { name: 'LogLine', doc: '', fields: [['seq', 'u64', ''], ['ts', 'i64', ''], ['level', 'String', ''], ['target', 'String', ''], ['msg', 'String', '']] }, { name: 'DirectoryStatus', doc: '', fields: [ ['configured', 'bool', 'An address and token are set.'], ['name', 'Option', 'The token’s name, as the directory knows it.'], ['access', 'Option', 'ro, rw or rwa.'], ['error', 'Option', 'Why the directory would not say.'], ['shared', 'Option', 'How the last share went.'], ] }, { name: 'DirectoryAdmin', doc: '', fields: [['instances', 'Vec', 'Servers sharing.'], ['blocked', 'Vec', '']] }, { name: 'BlockedRow', doc: 'A feed the directory keeps out. Never its address.', fields: [['id', 'String', ''], ['title', 'Option', ''], ['at', 'i64', 'When.']] }, { name: 'Instance', doc: 'A server sharing with the directory.', fields: [['name', 'String', 'Its token’s name.'], ['feeds', 'i64', ''], ['subscribers', 'i64', '']] }, { name: 'SnapshotResult', doc: 'What the directory made of a share.', fields: [['accepted', 'usize', ''], ['refused_private', 'usize', 'Addresses that look like someone’s paid access.'], ['refused_blocked', 'usize', ''], ['refused_invalid', 'usize', 'Not http(s).']] }, ], }; export const directory = { name: 'ipx_directory', label: 'ipx-directory', root: '/docs/api/directory', changelog: 'crates/directory/CHANGELOG.md', intro: `

The HTTP API of the directory of public podcasts that iPX servers share, at ${D}/v1. Its pages at ${D.replace('https://', '')} are anyone’s to browse; the API needs a token for everything.

Tokens

Authorization: Bearer <token>

A token has a name and one of three levels of access: ro reads, rw also shares, and rwa also removes and blocks feeds and drops a server’s share. Tokens are given out by whoever runs the directory, and none can be made or revoked over the API.

Errors

A request that fails answers with its status and {"error": "…"}: 401 no token or an unknown one, 403 a token without the access, 404 not listed.

Ids

A feed’s id is the first 16 hex digits of the SHA-256 of its address with the scheme and host in lower case and no default port or fragment, so every server arrives at the same one. Descriptions are the publisher’s own HTML, unsanitized: sanitize them before you show them.

`, groups: [ { id: 'feeds', name: 'feeds', title: 'Feeds', summary: 'What the directory lists.', items: [ { method: 'GET', path: '/v1/me', slug: 'get-me', access: 'ro', summary: 'The token’s name and access.', returns: 'Me', example: dirCurl('GET', '/v1/me'), }, { method: 'GET', path: '/v1/feeds', slug: 'get-feeds', access: 'ro', summary: 'Every feed listed, A to Z.', returns: 'Vec', example: dirCurl('GET', '/v1/feeds'), }, { method: 'GET', path: '/v1/popular', slug: 'get-popular', access: 'ro', summary: 'The most subscribed feeds.', query: 'Limit', returns: 'Vec', example: dirCurl('GET', '/v1/popular?limit=5'), }, { method: 'GET', path: '/v1/feeds/{id}', slug: 'get-feed', access: 'ro', summary: 'One feed, with its description and latest twenty items.', pathParams: [['id', 'String', 'The feed’s id.']], returns: 'FeedPage', errors: [['404 Not Found', 'Not listed.']], }, { method: 'GET', path: '/healthz', slug: 'get-healthz', access: 'none', summary: 'ok when the directory can read its database.', returns: 'text/plain', }, ], }, { id: 'share', name: 'share', title: 'Sharing', summary: 'A server telling the directory what its people follow.', items: [ { method: 'POST', path: '/v1/snapshot', slug: 'post-snapshot', access: 'rw', summary: 'Share: every public feed this server’s people follow, and how many follow each.', doc: '

Replaces everything this token shared before, so send the whole list each time. At most 20,000 feeds. Addresses that look private (a login, a key in the query, a paid-feed host) are refused and counted, never stored. A feed is listed once the directory has read it as one.

', body: 'Vec', returns: 'SnapshotResult', errors: [['413 Payload Too Large', 'More than 20,000 feeds.']], example: dirCurl('POST', '/v1/snapshot', '[{"url":"https://feeds.99percentinvisible.org/99percentinvisible","subscribers":3}]'), }, ], }, { id: 'admin', name: 'admin', title: 'Administering it', summary: 'Removing and blocking feeds, and the servers sharing. rwa tokens only.', items: [ { method: 'DELETE', path: '/v1/feeds/{id}', slug: 'delete-feed', access: 'rwa', summary: 'Remove a feed and block it, so no server’s share brings it back.', pathParams: [['id', 'String', 'The feed’s id.']], returns: '204 No Content', }, { method: 'POST', path: '/v1/feeds/{id}/refresh', slug: 'post-refresh', access: 'rwa', summary: 'Check a feed now, answering with its page.', pathParams: [['id', 'String', 'The feed’s id.']], returns: 'FeedPage', }, { method: 'GET', path: '/v1/blocked', slug: 'get-blocked', access: 'rwa', summary: 'The feeds kept out.', returns: 'Vec', }, { method: 'DELETE', path: '/v1/blocked/{id}', slug: 'delete-blocked', access: 'rwa', summary: 'Let a blocked feed back in.', pathParams: [['id', 'String', 'The feed’s id.']], returns: '204 No Content', }, { method: 'GET', path: '/v1/instances', slug: 'get-instances', access: 'rwa', summary: 'Each token holding a share, with how many feeds and subscribers it gave.', returns: 'Vec', }, { method: 'DELETE', path: '/v1/instances/{name}', slug: 'delete-instance', access: 'rwa', summary: 'Drop one token’s share, its counts with it, until it next shares.', doc: '

To keep a server out for good, revoke its token instead.

', pathParams: [['name', 'String', 'The token’s name.']], returns: '204 No Content', }, ], }, ], types: [ { name: 'Me', doc: 'A token, as the directory knows it.', fields: [['name', 'String', ''], ['access', 'Access', '']] }, { name: 'Access', doc: 'A token’s access: "ro", "rw" or "rwa".', fields: [] }, { name: 'Limit', doc: 'A query parameter.', fields: [['limit', 'Option', 'From 1 to 100; 10 if left out.']] }, { name: 'Listing', doc: 'A feed listed.', fields: [ ['id', 'String', ''], ['url', 'String', 'Its address, to subscribe to anywhere.'], ['title', 'Option', ''], ['image', 'Option', ''], ['category', 'Option', 'Apple’s category, from the feed’s own.'], ['subcategory', 'Option', ''], ['podcast', 'bool', 'Audio or video in its latest items.'], ['subscribers', 'i64', 'Summed over every server’s latest share.'], ] }, { name: 'FeedPage', doc: 'A feed’s page: every field of Listing, and these.', fields: [ ['description', 'Option', 'The feed’s own HTML, unsanitized.'], ['failing', 'Option', 'Why its last check failed, in words.'], ['items', 'Vec', 'Its latest twenty.'], ] }, { name: 'Item', doc: 'An episode or post. Never its file.', fields: [['guid', 'String', ''], ['title', 'Option', ''], ['link', 'Option', ''], ['published', 'Option', 'Unix seconds.'], ['duration', 'Option', 'Seconds.'], ['description', 'Option', 'Unsanitized.']] }, { name: 'SnapshotEntry', doc: 'One feed in a share.', fields: [['url', 'String', ''], ['subscribers', 'i64', ''], ['category', 'Option', 'A category for a feed that names none itself.']] }, { name: 'SnapshotResult', doc: 'What the directory made of a share.', fields: [['accepted', 'usize', ''], ['refused_private', 'usize', ''], ['refused_blocked', 'usize', ''], ['refused_invalid', 'usize', 'Not http(s).']] }, { name: 'Blocked', doc: '', fields: [['id', 'String', ''], ['url', 'Option', ''], ['title', 'Option', ''], ['at', 'i64', 'When.']] }, { name: 'Instance', doc: '', fields: [['name', 'String', 'The token’s name.'], ['feeds', 'i64', ''], ['subscribers', 'i64', '']] }, ], };