Docs: a page each, and API references for ipx and the directory laid out like docs.rs (#223)

site/gen.mjs writes them from site/api.mjs, which holds each endpoint and type as the handlers
have them, and site/docs.mjs, the guides. A misspelt type in api.mjs fails the build rather than
leaving a name that links nowhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 14:03:01 +00:00
parent 4cf568b263
commit e4b505564c
10 changed files with 1081 additions and 230 deletions

655
site/api.mjs Normal file
View File

@@ -0,0 +1,655 @@
// 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://<your server>: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 <token>"${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: `<p>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
<code>/api</code> and <code>/media</code> on to ipx at the same address, so a script talks to
the address people open in a browser.</p>
<h2 id="auth">Signing in</h2>
<p>Send an API token, made in Settings under API tokens or with <a href="${'/docs/api/ipx/auth/post-tokens'}"><code>POST /api/tokens</code></a>:</p>
<pre><code>Authorization: Bearer ipx_…</code></pre>
<p>A token acts as the person who made it, and is an admin only if they are. A browser signs in
with <a href="/docs/api/ipx/auth/post-login"><code>POST /api/login</code></a> 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 <code>CF-Access-Client-Id</code> and
<code>CF-Access-Client-Secret</code> beside the API token.</p>
<h2 id="errors">Errors</h2>
<p>A request that fails answers with its status and a sentence saying why, as plain text:
<code>401</code> not signed in, <code>403</code> an admin's to do, <code>404</code> not there or
not yours, <code>400</code> a request that cannot be done as asked, <code>409</code> a conflict
the answer explains.</p>
<h2 id="ids">Ids</h2>
<p>A feed's id is a short name made from its title, such as <code>99-invisible</code>, and
appears in the page's address when the feed is open. An episode is its feed's id and its
<code>guid</code>, the guid URL-encoded in a path. A file (an enclosure) has a number. Times are
Unix seconds.</p>`,
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: '<p>Answers who signed in and sets the session cookie, which lasts <code>session_days</code> from the last request. A script is better off with an API token, which needs no password kept.</p>',
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<Token>',
},
{
method: 'POST', path: '/api/tokens', slug: 'post-tokens',
summary: 'Make an API token that acts as you.',
doc: '<p>The answer is the only time the token is shown: only its SHA-256 is kept.</p>',
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 <code>GET /api/tokens</code>.']],
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<FeedRow>',
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: '<p>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.</p>',
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: '<p>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.</p>',
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: '<p>The check runs in the background; <a href="/docs/api/ipx/server/get-events"><code>GET /api/events</code></a> shows it as it goes.</p>',
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: '<p>Answers with the feed’s row, its counts changed, so a list can redraw one line.</p>',
pathParams: [['feed', 'String', 'The feed’s id.'], ['guid', 'String', 'The episode’s guid, URL-encoded.']],
body: 'Flags', returns: 'Option<FeedRow>', 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 <code>EncRow</code>’s <code>id</code>.']],
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: '<p>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 <code>?force=true</code> to delete it anyway.</p>',
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 <code>force=true</code> to go ahead.'], NOT_SUBSCRIBED],
},
{
method: 'GET', path: '/media/{id}', slug: 'get-media',
summary: 'The file itself, with <code>Range</code> 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: '<p>Only once DeadAir has looked at the file: an <code>EncRow</code> with <code>deadair</code> set says how many seconds it saves. <code>/media/{id}/deadair</code> gives the shortened episode as one file to save.</p>',
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<PopularRow>', errors: [NO_DIRECTORY],
},
{
method: 'GET', path: '/api/directory', slug: 'get-directory',
summary: 'Every feed the directory lists, A to Z.',
returns: 'Vec<PopularRow>', 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: '<p>The numbers <code>ipx status</code> prints, for a dashboard.</p>',
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: '<p>Each message’s data is one JSON <a href="/docs/api/ipx/types/Event"><code>Event</code></a>. Every event of a check reaches everyone; a feed’s new row (<code>feed_row</code>) only those who subscribe to it.</p>',
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<UserRow>', errors: [NOT_ADMIN],
},
{
method: 'POST', path: '/api/users', slug: 'post-users', access: 'admin',
summary: 'Add an account.',
doc: '<p>With no password, the account signs in only through single sign-on.</p>',
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: '<p>Only when this server’s directory token is <code>rwa</code>; the directory refuses otherwise.</p>',
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 <code>rwa</code> 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 <code>rwa</code>.',
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 <code>rwa</code>.',
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 <code>rwa</code>.',
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<String>', 'Words that hide an item in every feed you read. <code>GET /api/me</code> only, as are the fields below.'],
['sign_out', 'Option<String>', '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 <code>PATCH /api/me</code> changes.', fields: [['blocked', 'Option<Vec<String>>', 'Replaces the list.']] },
{ name: 'Token', doc: 'An API token, as listed.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['created', 'i64', ''], ['last_used', 'Option<i64>', '']] },
{ 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 <code>ipx_</code>. Send it as <code>Authorization: Bearer</code>.']] },
{ 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<String>', ''], ['image', 'Option<String>', ''],
['folder', 'Option<String>', ''],
['category', 'Option<String>', 'The directory category an admin gave it.'],
['feed_category', 'Option<String>', 'The one the feed names itself, which wins.'],
['keywords', 'Vec<String>', 'Your words: with any, only episodes that mention one are downloaded.'],
['blocked', 'Vec<String>', '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<usize>', 'Your own limit, or none for the server’s.'],
['group', 'Option<String>', 'The OPML list it came from.'],
['orphaned', 'bool', 'In a list that no longer has it, kept for its downloads.'],
['managed', 'bool', ''],
['schedule', 'Option<String>', 'Its own schedule, such as <code>every 2h</code>.'],
['schedule_mins', 'Option<u64>', 'The same in minutes.'],
['every_mins', 'u64', 'How often it is checked, all things considered.'],
['last_checked', 'Option<i64>', ''], ['next_check', 'Option<i64>', ''],
['last_error', 'Option<String>', ''],
['failing', 'Option<FailingRow>', '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<String>', '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<String>', ''],
['keywords', 'Vec<String>', '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 <code>PATCH /api/feeds/{id}</code> changes. Leave out what stays; <code>null</code> clears a field that can be cleared.', fields: [
['keywords', 'Option<Vec<String>>', 'Yours.'], ['blocked', 'Option<Vec<String>>', 'Yours.'],
['allow_explicit', 'Option<bool>', 'Yours.'], ['auto_download', 'Option<bool>', 'Yours.'],
['max_new_per_check', 'Option<Option<usize>>', 'Yours; <code>null</code> for the server’s.'],
['pinned', 'Option<bool>', 'Yours.'],
['deadair', 'Option<Option<bool>>', 'Yours; <code>null</code> follows its OPML list’s.'],
['deadair_pause', 'Option<i64>', 'Yours: 200, 300, 400 or 500.'],
['private', 'Option<bool>', 'Anyone subscribed may mark it private; only an admin unmarks it.'],
['url', 'Option<String>', 'Admin.'], ['schedule', 'Option<Option<String>>', 'Admin.'],
['folder', 'Option<Option<String>>', 'Admin.'], ['category', 'Option<Option<String>>', '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<String>', '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<String>', '<code>unread</code>, <code>downloaded</code> or <code>flagged</code> (kept).'],
['q', 'Option<String>', 'Words to look for in titles and show notes.'],
['sort', 'Option<String>', '<code>published</code>, <code>title</code>, <code>feed</code>, <code>type</code>, <code>size</code> or <code>kept</code>. Newest first if left out.'],
['dir', 'Option<String>', '<code>asc</code> or <code>desc</code>.'],
] },
{ name: 'EntryPage', doc: 'A page of episodes.', fields: [['total', 'i64', 'How many match, over every page.'], ['entries', 'Vec<EntryRow>', '']] },
{ name: 'EntryRow', doc: 'An episode or post, with your state for it.', fields: [
['guid', 'String', ''], ['feed_id', 'String', ''], ['title', 'Option<String>', ''], ['link', 'Option<String>', ''],
['published', 'Option<i64>', ''],
['description', 'Option<String>', 'Show notes, HTML, already sanitized.'],
['read', 'bool', ''], ['flagged', 'bool', 'Kept: its file is never cleared to save space.'],
['image', 'Option<String>', ''], ['duration', 'Option<i64>', 'Seconds.'],
['episode', 'Option<i64>', ''], ['season', 'Option<i64>', ''],
['position', 'i64', 'Where you are in it, in seconds.'],
['enclosures', 'Vec<EncRow>', 'Its files.'],
] },
{ name: 'EncRow', doc: 'An episode’s file.', fields: [
['id', 'i64', 'What <code>/media/{id}</code> and the file routes take.'],
['feed_id', 'String', ''], ['guid', 'String', ''], ['url', 'String', 'Where the publisher has it.'],
['mime', 'Option<String>', ''], ['length', 'Option<i64>', 'Bytes.'],
['path', 'Option<String>', 'Where it is on the server, once downloaded.'],
['state', 'String', '<code>pending</code> (a check will download it), <code>held</code> (back catalogue, downloaded on request), <code>downloading</code>, <code>done</code>, <code>skipped</code> (a filter or a failure) or <code>reaped</code> (deleted).'],
['last_error', 'Option<String>', ''],
['deadair', 'Option<i64>', '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<bool>', ''], ['flagged', 'Option<bool>', 'Kept.']] },
{ name: 'Position', doc: '', fields: [['secs', 'i64', 'Seconds in.'], ['duration', 'Option<i64>', '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<String>', ''], ['image', 'Option<String>', ''],
['subscribers', 'i64', 'On every server sharing with the directory.'],
['subscribed', 'bool', 'Yours already.'], ['feed', 'Option<String>', 'Its id here, when it is yours.'],
['category', 'Option<String>', ''], ['subcategory', 'Option<String>', ''],
['podcast', 'bool', 'Has audio or video.'],
] },
{ name: 'Listed', doc: 'A feed’s page in the directory.', fields: [
['description', 'Option<String>', 'What it says it is, sanitized.'],
['failing', 'Option<String>', 'Why its last check failed, when it did.'],
['items', 'Vec<ListedItem>', 'Its latest twenty.'],
['can_admin', 'bool', 'You may remove or refresh it.'],
] },
{ name: 'ListedItem', doc: '', fields: [['guid', 'String', ''], ['title', 'Option<String>', ''], ['link', 'Option<String>', ''], ['published', 'Option<i64>', ''], ['duration', 'Option<i64>', ''], ['description', 'Option<String>', '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. <code>ev</code> 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 <code>every 60m</code>.'], ['every_mins', 'u64', 'The same in minutes.'],
['max_new_per_check', 'usize', 'The newest episodes a check downloads, a feed.'],
['media_types', 'Vec<String>', 'What is downloaded, such as <code>audio</code>, <code>video</code>.'],
['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 <code>PATCH /api/settings</code> changes. Leave out what stays.', fields: [
['schedule', 'Option<String>', ''], ['max_new_per_check', 'Option<usize>', ''], ['media_types', 'Option<Vec<String>>', ''],
['max_total_gb', 'Option<f64>', ''], ['max_age_days', 'Option<u64>', ''], ['art_cache_mb', 'Option<u64>', ''],
['directory_url', 'Option<String>', 'Empty for no directory.'], ['directory_token', 'Option<String>', 'Replaces it; empty clears it.'],
['directory_share', 'Option<bool>', ''],
] },
{ 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<i64>', ''], ['last_login', 'Option<i64>', '']] },
{ 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 <code>seq</code> you have; 0 for the newest lines.'], ['limit', 'usize', '200 if left out.']] },
{ name: 'LogPage', doc: '', fields: [['lines', 'Vec<LogLine>', ''], ['latest', 'u64', 'The newest <code>seq</code>, 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<String>', 'The token’s name, as the directory knows it.'],
['access', 'Option<String>', '<code>ro</code>, <code>rw</code> or <code>rwa</code>.'], ['error', 'Option<String>', 'Why the directory would not say.'],
['shared', 'Option<SnapshotResult>', 'How the last share went.'],
] },
{ name: 'DirectoryAdmin', doc: '', fields: [['instances', 'Vec<Instance>', 'Servers sharing.'], ['blocked', 'Vec<BlockedRow>', '']] },
{ name: 'BlockedRow', doc: 'A feed the directory keeps out. Never its address.', fields: [['id', 'String', ''], ['title', 'Option<String>', ''], ['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: `<p>The HTTP API of the directory of public podcasts that iPX servers share, at
<code>${D}/v1</code>. Its pages at <a href="${D}">${D.replace('https://', '')}</a> are anyone’s
to browse; the API needs a token for everything.</p>
<h2 id="auth">Tokens</h2>
<pre><code>Authorization: Bearer &lt;token&gt;</code></pre>
<p>A token has a name and one of three levels of access: <code>ro</code> reads,
<code>rw</code> also shares, and <code>rwa</code> 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.</p>
<h2 id="errors">Errors</h2>
<p>A request that fails answers with its status and <code>{"error": "…"}</code>:
<code>401</code> no token or an unknown one, <code>403</code> a token without the access,
<code>404</code> not listed.</p>
<h2 id="ids">Ids</h2>
<p>A feed’s <code>id</code> 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.</p>`,
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<Listing>', example: dirCurl('GET', '/v1/feeds'),
},
{
method: 'GET', path: '/v1/popular', slug: 'get-popular', access: 'ro',
summary: 'The most subscribed feeds.', query: 'Limit', returns: 'Vec<Listing>',
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: '<code>ok</code> 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: '<p>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.</p>',
body: 'Vec<SnapshotEntry>', 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. <code>rwa</code> 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<Blocked>',
},
{
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<Instance>',
},
{
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: '<p>To keep a server out for good, revoke its token instead.</p>',
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: <code>"ro"</code>, <code>"rw"</code> or <code>"rwa"</code>.', fields: [] },
{ name: 'Limit', doc: 'A query parameter.', fields: [['limit', 'Option<u64>', '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<String>', ''], ['image', 'Option<String>', ''],
['category', 'Option<String>', 'Apple’s category, from the feed’s own.'], ['subcategory', 'Option<String>', ''],
['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 <a href="/docs/api/directory/types/Listing"><code>Listing</code></a>, and these.', fields: [
['description', 'Option<String>', 'The feed’s own HTML, unsanitized.'],
['failing', 'Option<String>', 'Why its last check failed, in words.'],
['items', 'Vec<Item>', 'Its latest twenty.'],
] },
{ name: 'Item', doc: 'An episode or post. Never its file.', fields: [['guid', 'String', ''], ['title', 'Option<String>', ''], ['link', 'Option<String>', ''], ['published', 'Option<i64>', 'Unix seconds.'], ['duration', 'Option<i64>', 'Seconds.'], ['description', 'Option<String>', 'Unsanitized.']] },
{ name: 'SnapshotEntry', doc: 'One feed in a share.', fields: [['url', 'String', ''], ['subscribers', 'i64', ''], ['category', 'Option<String>', '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<String>', ''], ['title', 'Option<String>', ''], ['at', 'i64', 'When.']] },
{ name: 'Instance', doc: '', fields: [['name', 'String', 'The token’s name.'], ['feeds', 'i64', ''], ['subscribers', 'i64', '']] },
],
};

View File

@@ -5,8 +5,10 @@ set -e
here=$(dirname "$0") here=$(dirname "$0")
rm -rf "$here/dist" rm -rf "$here/dist"
mkdir -p "$here/dist/fonts" mkdir -p "$here/dist/fonts"
cp "$here/index.html" "$here/docs.html" "$here/site.css" "$here/wave.svg" "$here/gitea.svg" "$here/_headers" "$here/dist/" cp "$here/index.html" "$here/site.css" "$here/wave.svg" "$here/gitea.svg" "$here/_headers" "$here/dist/"
# The reader's own typeface, the same file ipx-web serves. # The reader's own typeface, the same file ipx-web serves.
cp "$here/../web/InterVariable.woff2" "$here/dist/fonts/inter.woff2" cp "$here/../web/InterVariable.woff2" "$here/dist/fonts/inter.woff2"
cp "$here/../web/logo.svg" "$here/../web/favicon.png" "$here/dist/" cp "$here/../web/logo.svg" "$here/../web/favicon.png" "$here/dist/"
cp -r "$here/shots" "$here/dist/" cp -r "$here/shots" "$here/dist/"
# The docs: the guides and the API references, generated (#223).
node "$here/gen.mjs"

View File

@@ -1,218 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>iPX docs</title>
<meta name="description" content="How to run iPX and its page on your own server, and how to use iPX's API and the directory's.">
<meta name="color-scheme" content="light">
<link rel="icon" href="/favicon.png">
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/site.css">
</head>
<body>
<header class="bar">
<a class="home" href="/"><img src="/logo.svg" alt="" width="36" height="36">iPX</a>
<nav aria-label="Elsewhere">
<a href="/docs" aria-current="page">Docs</a>
<a href="https://directory.ipxreader.app">Directory</a>
<a class="src" href="https://git.sdf1.net/rays/ipx" title="Source code on git.sdf1.net"><img src="/gitea.svg" alt="Source code" width="28" height="28"></a>
</nav>
</header>
<main class="doc">
<h1 class="title">Docs</h1>
<p class="lede">iPX is two containers: <strong>ipx</strong>, which checks your podcasts, downloads them and answers the API, and <strong>ipx-web</strong>, which serves the page and passes the API on to ipx. The directory is a third, which you only need to run if you want a directory of your own.</p>
<nav class="toc" aria-label="On this page">
<ol>
<li><a href="#install">Install ipx and ipx-web</a></li>
<li><a href="#api">Use ipx from a script</a></li>
<li><a href="#directory">Use the directory</a></li>
</ol>
</nav>
<section aria-labelledby="install">
<h2 id="install">Install ipx and ipx-web</h2>
<p>You need Docker with Compose. There are no published images yet, so you build both from the source:</p>
<pre><code>git clone https://git.sdf1.net/rays/ipx.git
cd ipx
docker build -t ipx .
docker build -f Dockerfile.web -t ipx-web .</code></pre>
<p>The repository has a sample <code>docker-compose.yml</code> that runs both, with the database in SQLite and everything ipx keeps in three folders beside the file:</p>
<pre><code>services:
ipx:
image: ipx
container_name: ipx
restart: unless-stopped
environment:
PUID: "99" # who owns the files it writes
PGID: "100"
TZ: "UTC"
ports:
- "6881:6881/tcp" # BitTorrent
- "6881:6881/udp"
volumes:
- ./config:/config # config.toml, written on first start
- ./data:/data # the database
- ./downloads:/downloads
ipx-web:
image: ipx-web
container_name: ipx-web
restart: unless-stopped
environment:
IPX_UPSTREAM: "ipx:8099"
ports:
- "8099:8080" # the page
depends_on:
- ipx</code></pre>
<pre><code>docker compose up -d</code></pre>
<p>ipx has no port of its own: everything reaches it through ipx-web, on port 8099. Open <code>http://&lt;your server&gt;:8099</code> and sign in as <strong>admin</strong> with the password <strong>ipodderx</strong>, then change it:</p>
<pre><code>echo -n 'a good password' | docker exec -i ipx ipx user passwd admin</code></pre>
<p>The password is read from what you pipe in, so it stays out of your shell's history.</p>
<h3>Adding people</h3>
<p>An admin adds people in Admin, Accounts, or from the command line:</p>
<pre><code>echo -n 'their password' | docker exec -i ipx ipx user add sam</code></pre>
<p>Each person starts with no podcasts and subscribes to their own. Two people subscribing to the same show cost nothing extra: it is fetched once and each file is kept once.</p>
<h3>Settings</h3>
<p>How often podcasts are checked, how much disk to use, how many new episodes to download and the rest are in Admin on the page, and take effect straight away. <code>/config/config.toml</code> holds only what ipx needs before it can read its database: where downloads go, BitTorrent, and who gets in.</p>
<div class="scroll"><table>
<thead><tr><th>Environment variable</th><th>What it does</th></tr></thead>
<tbody>
<tr><td><code>IPX_DATABASE_URL</code></td><td>A <code>postgres://user:password@host:5432/database</code> address, to keep everything in Postgres instead of SQLite</td></tr>
<tr><td><code>PUID</code>, <code>PGID</code></td><td>The user and group that own the files ipx writes</td></tr>
<tr><td><code>IPX_LOG_FORMAT</code></td><td><code>json</code> logs one JSON object a line, for Loki and the like</td></tr>
<tr><td><code>OTEL_EXPORTER_OTLP_ENDPOINT</code></td><td>Sends traces of requests, checks and downloads to an OpenTelemetry collector; set <code>WEB_TRACING=on</code> on ipx-web too</td></tr>
<tr><td><code>IPX_UPSTREAM</code></td><td>On ipx-web: where ipx is, <code>ipx:8099</code> by default</td></tr>
</tbody>
</table></div>
<h3>Reaching it from outside</h3>
<p>The page is plain HTTP. If anyone will reach it from outside your network, put HTTPS in front of it: a reverse proxy you already run, or a Cloudflare tunnel. People can then sign in through Cloudflare Access or Authentik instead of a password; <a href="https://git.sdf1.net/rays/ipx/src/branch/main/docs/sso.md">single sign-on</a> says how.</p>
<h3>Updating</h3>
<pre><code>git pull
docker build -t ipx .
docker build -f Dockerfile.web -t ipx-web .
docker compose up -d</code></pre>
<p>Your podcasts, accounts and downloads are in the three folders and are kept.</p>
</section>
<section aria-labelledby="api">
<h2 id="api">Use ipx from a script</h2>
<p>Anything the page does, a script can do through the same API.</p>
<h3>A token</h3>
<p>In Settings, under API tokens, give a token a name and press Make. It is shown once, so copy it then. It acts as you: it sees your podcasts and changes them, and it is an admin only if you are. Revoke it in the same place.</p>
<pre><code>curl -H "Authorization: Bearer ipx_…" http://&lt;your server&gt;:8099/api/feeds</code></pre>
<p>Behind Cloudflare Access, a token alone does not get past Access. Give the script an Access service token as well, and send its <code>CF-Access-Client-Id</code> and <code>CF-Access-Client-Secret</code> beside the token.</p>
<h3>The API</h3>
<p>Requests and answers are JSON. A feed's id is the one in its list's address, such as <code>99-invisible</code>; an episode is named by its feed and its guid, the guid URL-encoded.</p>
<div class="scroll"><table>
<thead><tr><th>Request</th><th>What it does</th></tr></thead>
<tbody>
<tr><td><code>GET /api/me</code></td><td>Who you are, and the version</td></tr>
<tr><td><code>GET /api/feeds</code></td><td>Your podcasts, each with its unread and downloaded counts</td></tr>
<tr><td><code>POST /api/feeds</code></td><td>Subscribe: <code>{"url": "…"}</code>, a feed's address or a site that links one; optional <code>folder</code>, <code>keywords</code></td></tr>
<tr><td><code>DELETE /api/feeds/{id}</code></td><td>Unsubscribe. The files stay for anyone else who subscribes</td></tr>
<tr><td><code>PATCH /api/feeds/{id}</code></td><td>Your settings for a podcast</td></tr>
<tr><td><code>GET /api/feeds/{id}/entries</code></td><td>A podcast's episodes, 50 at a time: <code>offset</code>, <code>limit</code>, <code>filter</code> (<code>unread</code>, <code>downloaded</code> or <code>flagged</code>), <code>q</code> to search, <code>sort</code> and <code>dir</code></td></tr>
<tr><td><code>GET /api/entries</code></td><td>The same, across all your podcasts</td></tr>
<tr><td><code>POST /api/entries/{feed}/{guid}/flags</code></td><td>Mark an episode read or kept: <code>{"read": true}</code>, <code>{"flagged": true}</code></td></tr>
<tr><td><code>POST /api/entries/{feed}/{guid}/position</code></td><td>Where you are in it: <code>{"secs": 1234}</code></td></tr>
<tr><td><code>POST /api/feeds/{id}/read-all</code></td><td>Mark a podcast's episodes read; <code>POST /api/read-all</code> marks them all</td></tr>
<tr><td><code>POST /api/enclosures/{id}/download</code></td><td>Download an episode's file now</td></tr>
<tr><td><code>GET /media/{id}</code></td><td>The file itself, with seeking</td></tr>
<tr><td><code>POST /api/fetch</code></td><td>Check for new episodes now</td></tr>
<tr><td><code>GET /api/opml</code>, <code>POST /api/opml</code></td><td>Your subscriptions as OPML, out or in</td></tr>
<tr><td><code>GET /api/events</code></td><td>A live stream (server-sent events) of checks and downloads as they happen</td></tr>
</tbody>
</table></div>
<p>For example, the newest unread episodes across everything you follow:</p>
<pre><code>curl -H "Authorization: Bearer ipx_…" \
"http://&lt;your server&gt;:8099/api/entries?filter=unread&amp;limit=10"</code></pre>
<h3>The command line</h3>
<p>The same <code>ipx</code> program that runs the server is its command line. Run it in the container with <code>docker exec ipx ipx …</code>; when the server is running, it does the work and the command shows what happened.</p>
<div class="scroll"><table>
<thead><tr><th>Command</th><th>What it does</th></tr></thead>
<tbody>
<tr><td><code>ipx list</code></td><td>Every podcast and its state</td></tr>
<tr><td><code>ipx status</code></td><td>How many podcasts, how many files waiting and downloaded</td></tr>
<tr><td><code>ipx fetch [id] [--force]</code></td><td>Check everything that is due, or one podcast; <code>--force</code> checks it even if it is not due</td></tr>
<tr><td><code>ipx add &lt;url&gt;</code>, <code>ipx rm &lt;id&gt;</code></td><td>Add a podcast, or take it out</td></tr>
<tr><td><code>ipx import &lt;file&gt;</code>, <code>ipx export &lt;file&gt;</code></td><td>OPML in or out</td></tr>
<tr><td><code>ipx reap [--dry-run]</code></td><td>Clear old files now, to stay within the space you set</td></tr>
<tr><td><code>ipx user add|list|passwd|rm</code></td><td>Accounts</td></tr>
</tbody>
</table></div>
<p>A program on the same machine can also talk to the running server over its control socket, one JSON command a line, and read back one event a line:</p>
<pre><code>printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/ipx.sock
{"ev":"feed_start","feed":"atp"}
{"ev":"download_done","feed":"atp","enclosure":42,"path":"…","bytes":3000000}
{"ev":"feed_done","feed":"atp","new":1,"downloaded":1,"failed":0,"torrents":0}
{"ev":"scan_done","feeds":1}</code></pre>
<p>The commands are <code>fetch</code> (optional <code>feed</code>, <code>force</code>), <code>download</code> (<code>enclosure</code>), <code>reap</code> (optional <code>dry_run</code>) and <code>status</code>.</p>
</section>
<section aria-labelledby="directory">
<h2 id="directory">Use the directory</h2>
<p>The directory gathers the public podcasts that iPX servers' listeners follow, and how many follow each. It never holds a file and never learns who listens.</p>
<h3>Browsing it</h3>
<p>Anyone can browse <a href="https://directory.ipxreader.app">directory.ipxreader.app</a> without signing in: the most followed, the categories, everything A to Z, and a page for each podcast with its latest episodes and its address, to subscribe to in any podcast app.</p>
<h3>Connecting your iPX</h3>
<p>In iPX, an admin opens Admin, Directory and enters the directory's address, <code>https://directory.ipxreader.app</code>, and the token its operator gave your server. Everyone on your server then has the Directory at the top of their list, and subscribes from it in one click.</p>
<p>Turn on sharing to add your server's podcasts to it. iPX sends only the podcasts' addresses and how many people on your server follow each, never who. It leaves out anything that looks private: a podcast someone marked Private, one with a login or a key in its address, and paid feeds such as Patreon's.</p>
<h3>Its API</h3>
<p>Everything under <code>/v1</code> takes a token, sent as <code>Authorization: Bearer &lt;token&gt;</code>. A token reads (<code>ro</code>), reads and shares (<code>rw</code>), or also removes and blocks podcasts (<code>rwa</code>).</p>
<div class="scroll"><table>
<thead><tr><th>Request</th><th>Token</th><th>What it does</th></tr></thead>
<tbody>
<tr><td><code>GET /v1/me</code></td><td>ro</td><td>The token's name and access</td></tr>
<tr><td><code>GET /v1/feeds</code></td><td>ro</td><td>Every podcast, A to Z: <code>id</code>, <code>url</code>, <code>title</code>, <code>image</code>, <code>category</code>, <code>subscribers</code></td></tr>
<tr><td><code>GET /v1/popular?limit=10</code></td><td>ro</td><td>The most followed, up to 100</td></tr>
<tr><td><code>GET /v1/feeds/{id}</code></td><td>ro</td><td>One podcast, with its description and latest twenty episodes</td></tr>
<tr><td><code>POST /v1/snapshot</code></td><td>rw</td><td>Share: <code>[{"url": "…", "subscribers": 3}]</code>, replacing what this token shared before</td></tr>
<tr><td><code>DELETE /v1/feeds/{id}</code></td><td>rwa</td><td>Remove a podcast and keep it out</td></tr>
<tr><td><code>POST /v1/feeds/{id}/refresh</code></td><td>rwa</td><td>Check a podcast now</td></tr>
<tr><td><code>GET /v1/blocked</code>, <code>DELETE /v1/blocked/{id}</code></td><td>rwa</td><td>What is kept out; let one back in</td></tr>
<tr><td><code>GET /v1/instances</code>, <code>DELETE /v1/instances/{name}</code></td><td>rwa</td><td>The servers sharing; drop one's share</td></tr>
</tbody>
</table></div>
<p>A podcast's <code>id</code> 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: sanitize them before you show them.</p>
<pre><code>curl -H "Authorization: Bearer &lt;token&gt;" "https://directory.ipxreader.app/v1/popular?limit=5"</code></pre>
<h3>Running your own</h3>
<p>The directory is in the same repository. Build it, give it a database (Postgres or SQLite) and a config file, and make a token for each server that will use it:</p>
<pre><code>docker build -f Dockerfile.directory -t ipx-directory .</code></pre>
<pre><code>services:
ipx-directory:
image: ipx-directory
container_name: ipx-directory
restart: unless-stopped
environment:
DIRECTORY_DATABASE_URL: "postgres://ipx_directory:password@db-host:5432/ipx_directory"
ports:
- "8100:8100"
volumes:
- ./directory:/config:ro # directory.toml</code></pre>
<pre><code># directory/directory.toml
bind = "0.0.0.0:8100"
database_url_env = "DIRECTORY_DATABASE_URL"</code></pre>
<pre><code>docker exec ipx-directory ipx-directory token new my-server --access rw</code></pre>
<p>That prints the token, to give to the server, and the lines to add to <code>directory.toml</code>, which keeps only its SHA-256. The directory reads the file again within a minute, so adding or revoking a token needs no restart. It checks each podcast every six hours.</p>
</section>
</main>
<footer>
<p>iPX is free and open source under the MIT licence. The full documentation is in the <a href="https://git.sdf1.net/rays/ipx/src/branch/main/docs">repository</a>.</p>
</footer>
</body>
</html>

24
site/docs.mjs Normal file
View File

@@ -0,0 +1,24 @@
// The guides: a page each, from site/guides, with the docs' front page written here (#223).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const read = name => fs.readFileSync(path.join(here, 'guides', name), 'utf8');
const front = `<h1>Docs</h1>
<p class="lede">How to run iPX on your own server, and how to talk to it and to the directory from a program of your own.</p>
<dl class="cards">
<div><dt><a href="/docs/install">Install ipx and ipx-web</a></dt><dd>Build the two containers, start them, sign in and add people.</dd></div>
<div><dt><a href="/docs/cli">The command line</a></dt><dd>The <code>ipx</code> commands, and the control socket a program on the same machine can use.</dd></div>
<div><dt><a href="/docs/directory">The directory</a></dt><dd>Browsing it, connecting your iPX to it, and running one of your own.</dd></div>
<div><dt><a href="/docs/api/ipx/">ipx API reference</a></dt><dd>Every endpoint of an iPX server, what it takes and what it gives.</dd></div>
<div><dt><a href="/docs/api/directory/">ipx-directory API reference</a></dt><dd>The directory's API, for reading what it lists and sharing with it.</dd></div>
</dl>`;
export const guides = [
{ href: '/docs/', file: 'docs/index.html', name: 'Overview', title: 'iPX docs', description: 'How to run iPX on your own server, and how to use its API and the directory’s.', body: front },
{ href: '/docs/install', file: 'docs/install.html', name: 'Install', title: 'Install ipx and ipx-web - iPX docs', description: 'Run iPX and its page on your own server with Docker.', body: read('install.html') },
{ href: '/docs/cli', file: 'docs/cli.html', name: 'Command line', title: 'The command line - iPX docs', description: 'The ipx commands, and its control socket.', body: read('cli.html') },
{ href: '/docs/directory', file: 'docs/directory.html', name: 'Directory', title: 'The directory - iPX docs', description: 'Browse the directory, connect your iPX to it, or run your own.', body: read('directory.html') },
];

212
site/gen.mjs Normal file
View File

@@ -0,0 +1,212 @@
// The docs pages, into site/dist/docs: the guides in docs.mjs, and an API reference for each
// service from api.mjs, laid out as docs.rs lays out a crate (#223): a root page listing its
// modules and types, a page a module listing its endpoints, a page an endpoint and a page a type.
// No script: the site's Content-Security-Policy allows none of its own.
//
// node site/gen.mjs (site/build.sh runs it)
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ipx, directory } from './api.mjs';
import { guides } from './docs.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const dist = path.join(here, 'dist');
const SRC = 'https://git.sdf1.net/rays/ipx/src/branch/main';
const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/// The version a reference documents: its service's last release, from its changelog, not the
/// -dev version between releases.
function released(changelog) {
const m = fs.readFileSync(path.join(here, '..', changelog), 'utf8').match(/^## \[(\d+\.\d+\.\d+)\]/m);
return m ? m[1] : '';
}
function write(rel, html) {
const file = path.join(dist, rel);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, html);
}
function shell({ title, description, sidebar, body, current }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)}</title>
<meta name="description" content="${esc(description)}">
<meta name="color-scheme" content="light">
<link rel="icon" href="/favicon.png">
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/site.css">
</head>
<body>
<header class="bar">
<a class="home" href="/"><img src="/logo.svg" alt="" width="36" height="36">iPX</a>
<nav aria-label="Elsewhere">
<a href="/docs/" aria-current="true">Docs</a>
<a href="https://directory.ipxreader.app">Directory</a>
<a class="src" href="https://git.sdf1.net/rays/ipx" title="Source code on git.sdf1.net"><img src="/gitea.svg" alt="Source code" width="28" height="28"></a>
</nav>
</header>
<div class="docs">
<aside class="side">${sidebar}</aside>
<main class="doc">${body}</main>
</div>
<footer>
<p>iPX is free and open source under the MIT licence.</p>
</footer>
</body>
</html>
`;
}
/* ---------------- the guides ---------------- */
const REFS = [[ipx, 'ipx'], [directory, 'ipx-directory']];
function guideSidebar(cur) {
const li = (href, name, on) => `<li><a href="${href}"${on ? ' aria-current="page"' : ''}>${name}</a></li>`;
return `<nav aria-label="Docs">
<h2 class="side-h">Guides</h2>
<ul>${guides.map(g => li(g.href, g.name, g.href === cur)).join('')}</ul>
<h2 class="side-h">API reference</h2>
<ul>${REFS.map(([r, label]) => li(r.root + '/', label, false)).join('')}</ul>
</nav>`;
}
for (const g of guides) {
write(g.file, shell({ title: g.title, description: g.description, sidebar: guideSidebar(g.href), body: g.body, current: 'docs' }));
}
/* ---------------- the references ---------------- */
const METHOD_CLASS = { GET: 'm-get', POST: 'm-post', PATCH: 'm-patch', PUT: 'm-patch', DELETE: 'm-delete' };
function reference(api) {
const label = api.label || api.name;
const version = released(api.changelog);
const typeNames = new Set(api.types.map(t => t.name));
const typeHref = n => `${api.root}/types/${n}`;
const itemHref = (g, it) => `${api.root}/${g.id}/${it.slug}`;
const fnName = it => it.slug.replace(/-/g, '_');
// A Rust type, its names that are types of this API linked.
const ty = s => esc(s).replace(/\b[A-Z][A-Za-z]+\b/g, n => typeNames.has(n) ? `<a class="t" href="${typeHref(n)}">${n}</a>` : n);
const isType = s => /^[A-Z]/.test(s) && /[A-Za-z>]$/.test(s) && !/\s/.test(s.replace(/, /g, ','));
const access = it => it.access || 'signed in';
// Which endpoints mention each type, for its "Used by", as docs.rs lists where a type turns up.
const usedBy = new Map();
for (const g of api.groups) for (const it of g.items) {
for (const s of [it.body, it.query, it.returns].filter(Boolean)) {
for (const n of s.match(/\b[A-Z][A-Za-z]+\b/g) || []) if (typeNames.has(n)) {
if (!usedBy.has(n)) usedBy.set(n, []);
if (!usedBy.get(n).some(([, x]) => x === it)) usedBy.get(n).push([g, it]);
}
}
}
// Types inside types: a type used only as a field is reached from the type that holds it.
for (const t of api.types) for (const [, ft] of t.fields) {
for (const n of ft.match(/\b[A-Z][A-Za-z]+\b/g) || []) if (typeNames.has(n) && n !== t.name) {
if (!usedBy.has(n)) usedBy.set(n, []);
if (!usedBy.get(n).some(([, x]) => x === t)) usedBy.get(n).push([null, t]);
}
}
const crumbs = (...parts) => `<p class="path"><a href="${api.root}/">${esc(api.name)}</a>${parts.map(([h, n]) => `::<a href="${h}">${n}</a>`).join('')}</p>`;
const top = `<a class="crate" href="${api.root}/"><img src="/logo.svg" alt="" width="40" height="40"><span><b>${esc(label)}</b>${version ? `<span class="ver">${version}</span>` : ''}</span></a>`;
const back = `<p class="side-back"><a href="/docs/">All docs</a></p>`;
function sidebar({ group, item, type } = {}) {
let s = top;
if (group) {
s += `<h2 class="side-h">Module <a href="${api.root}/${group.id}/">${group.name}</a></h2>
<ul class="items">${group.items.map(it => `<li><a class="${METHOD_CLASS[it.method]}" href="${itemHref(group, it)}" title="${it.method} ${esc(it.path)}"${it === item ? ' aria-current="page"' : ''}><span class="verb">${it.method}</span> ${fnName(it)}</a></li>`).join('')}</ul>`;
}
s += `<h2 class="side-h">Modules</h2><ul>${api.groups.map(g => `<li><a href="${api.root}/${g.id}/"${g === group && !item ? ' aria-current="page"' : ''}>${g.name}</a></li>`).join('')}</ul>`;
s += `<h2 class="side-h">Types</h2><ul class="types">${api.types.map(t => `<li><a class="t" href="${typeHref(t.name)}"${t === type ? ' aria-current="page"' : ''}>${t.name}</a></li>`).join('')}</ul>`;
return `<nav aria-label="${esc(label)} reference">${s}</nav>${back}`;
}
const page = (rel, title, description, side, body) =>
write(rel, shell({ title, description, sidebar: side, body, current: 'docs' }));
// The root: what the crate page shows on docs.rs, its modules and types.
page(`${api.root.slice(1)}/index.html`, `${label} API`, `The HTTP API of ${label}, endpoint by endpoint.`, sidebar(),
`<h1 class="item"><span class="kind">API</span> <span class="name">${esc(label)}</span>${version ? ` <span class="ver">${version}</span>` : ''}</h1>
${api.intro}
<h2 id="modules">Modules</h2>
<dl class="list">${api.groups.map(g => `<dt><a href="${api.root}/${g.id}/">${g.name}</a></dt><dd>${g.summary}</dd>`).join('')}</dl>
<h2 id="types">Types</h2>
<dl class="list">${api.types.map(t => `<dt><a class="t" href="${typeHref(t.name)}">${t.name}</a></dt><dd>${t.doc || ''}</dd>`).join('')}</dl>`);
for (const g of api.groups) {
page(`${api.root.slice(1)}/${g.id}/index.html`, `${api.name}::${g.name}`, g.title, sidebar({ group: g }),
`${crumbs()}<h1 class="item"><span class="kind">Module</span> ${esc(api.name)}::<span class="name">${g.name}</span></h1>
<p>${g.summary}</p>
<h2 id="endpoints">Endpoints</h2>
<dl class="list">${g.items.map(it => `<dt><a class="${METHOD_CLASS[it.method]}" href="${itemHref(g, it)}"><span class="verb">${it.method}</span> ${esc(it.path)}</a></dt><dd>${it.summary}</dd>`).join('')}</dl>`);
for (const it of g.items) {
const decl = [
`<span class="verb ${METHOD_CLASS[it.method]}">${it.method}</span> ${esc(it.path)}`,
it.query && ` query ${ty(it.query)}`,
it.body && ` body ${ty(it.body)}`,
` -&gt; ${isType(it.returns) ? ty(it.returns) : esc(it.returns)}`,
].filter(Boolean).join('\n');
const fieldsOf = n => api.types.find(t => t.name === n)?.fields || [];
const rows = list => `<dl class="fields">${list.map(([n, t, d]) => `<dt id="${it.slug}-${n}"><code>${esc(n)}: ${ty(t)}</code></dt><dd>${d || ''}</dd>`).join('')}</dl>`;
let body = `${crumbs([`${api.root}/${g.id}/`, g.name])}
<h1 class="item"><span class="kind">Endpoint</span> ${esc(api.name)}::${g.name}::<span class="name fn">${fnName(it)}</span><a class="srclink" href="${SRC}/${api.name === 'ipx' ? 'crates/ipx/src/web.rs' : 'crates/directory/src/lib.rs'}">Source</a></h1>
<pre class="decl"><code>${decl}</code></pre>
<p class="summary">${it.summary}</p>
${it.doc || ''}
<p class="access">Access: <b>${esc(access(it))}</b></p>`;
if (it.pathParams) body += `<h2>Path parameters</h2>${rows(it.pathParams)}`;
if (it.query) body += `<h2>Query</h2><p>${ty(it.query)}, as query parameters:</p>${rows(fieldsOf(it.query))}`;
if (it.body) body += `<h2>Body</h2><p>${ty(it.body)}, as JSON.</p>`;
body += `<h2>Returns</h2><p>${isType(it.returns) ? `${ty(it.returns)}, as JSON.` : esc(it.returns)}</p>`;
if (it.errors?.length) body += `<h2>Errors</h2><dl class="fields">${it.errors.map(([c, d]) => `<dt><code>${esc(c)}</code></dt><dd>${d}</dd>`).join('')}</dl>`;
if (it.example) body += `<h2>Example</h2><pre><code>${esc(it.example)}</code></pre>`;
page(`${api.root.slice(1)}/${g.id}/${it.slug}.html`, `${it.method} ${it.path} - ${label}`, it.summary.replace(/<[^>]+>/g, ''), sidebar({ group: g, item: it }), body);
}
}
page(`${api.root.slice(1)}/types/index.html`, `${api.name}::types`, `The types ${label}'s API takes and gives.`, sidebar(),
`${crumbs()}<h1 class="item"><span class="kind">Module</span> ${esc(api.name)}::<span class="name">types</span></h1>
<dl class="list">${api.types.map(t => `<dt><a class="t" href="${typeHref(t.name)}">${t.name}</a></dt><dd>${t.doc || ''}</dd>`).join('')}</dl>`);
for (const t of api.types) {
const decl = t.name === 'Event'
? `enum Event {\n${t.fields.map(([n, f]) => ` ${n} ${ty(f)},`).join('\n')}\n}`
: t.fields.length
? `struct ${t.name} {\n${t.fields.map(([n, f]) => ` ${n}: ${ty(f)},`).join('\n')}\n}`
: `enum ${t.name}`;
const uses = usedBy.get(t.name) || [];
let body = `${crumbs([`${api.root}/types/`, 'types'])}
<h1 class="item"><span class="kind">${t.name === 'Event' || !t.fields.length ? 'Enum' : 'Struct'}</span> ${esc(api.name)}::types::<span class="name t">${t.name}</span></h1>
<pre class="decl"><code>${decl}</code></pre>
${t.doc ? `<p class="summary">${t.doc}</p>` : ''}`;
if (t.fields.length) {
body += `<h2>${t.name === 'Event' ? 'Variants' : 'Fields'}</h2><dl class="fields">${t.fields.map(([n, f, d]) =>
`<dt id="${n}"><a class="anchor" href="#${n}" aria-label="Link to ${esc(n)}">§</a><code>${esc(n)}${t.name === 'Event' ? ' ' : ': '}${ty(f)}</code></dt><dd>${d || ''}</dd>`).join('')}</dl>`;
}
if (uses.length) {
body += `<h2>Used by</h2><ul class="uses">${uses.map(([g, x]) => g
? `<li><a class="${METHOD_CLASS[x.method]}" href="${itemHref(g, x)}"><span class="verb">${x.method}</span> ${esc(x.path)}</a></li>`
: `<li><a class="t" href="${typeHref(x.name)}">${x.name}</a></li>`).join('')}</ul>`;
}
page(`${api.root.slice(1)}/types/${t.name}.html`, `${t.name} - ${label}`, (t.doc || `${t.name} in ${label}'s API`).replace(/<[^>]+>/g, ''), sidebar({ type: t }), body);
}
// Every link to a type must name one there is: a misspelt type in api.mjs is otherwise a
// name in plain text, which looks right and leads nowhere.
for (const g of api.groups) for (const it of g.items) for (const s of [it.body, it.query].filter(Boolean)) {
for (const n of s.match(/\b[A-Z][A-Za-z]+\b/g)) if (!typeNames.has(n) && !['Vec', 'Option'].includes(n)) throw new Error(`${it.path}: no type ${n}`);
}
}
reference(ipx);
reference(directory);

22
site/guides/cli.html Normal file
View File

@@ -0,0 +1,22 @@
<h1>The command line</h1>
<p>The same <code>ipx</code> program that runs the server is its command line. Run it in the container with <code>docker exec ipx ipx …</code>; when the server is running, it does the work and the command shows what happened.</p>
<div class="scroll"><table>
<thead><tr><th>Command</th><th>What it does</th></tr></thead>
<tbody>
<tr><td><code>ipx list</code></td><td>Every podcast and its state</td></tr>
<tr><td><code>ipx status</code></td><td>How many podcasts, how many files waiting and downloaded</td></tr>
<tr><td><code>ipx fetch [id] [--force]</code></td><td>Check everything that is due, or one podcast; <code>--force</code> checks it even if it is not due</td></tr>
<tr><td><code>ipx add &lt;url&gt;</code>, <code>ipx rm &lt;id&gt;</code></td><td>Add a podcast, or take it out</td></tr>
<tr><td><code>ipx import &lt;file&gt;</code>, <code>ipx export &lt;file&gt;</code></td><td>OPML in or out</td></tr>
<tr><td><code>ipx reap [--dry-run]</code></td><td>Clear old files now, to stay within the space you set</td></tr>
<tr><td><code>ipx user add|list|passwd|rm</code></td><td>Accounts</td></tr>
</tbody>
</table></div>
<h2>The control socket</h2>
<p>A program on the same machine can also talk to the running server over its control socket, one JSON command a line, and read back one event a line:</p>
<pre><code>printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/ipx.sock
{"ev":"feed_start","feed":"atp"}
{"ev":"download_done","feed":"atp","enclosure":42,"path":"…","bytes":3000000}
{"ev":"feed_done","feed":"atp","new":1,"downloaded":1,"failed":0,"torrents":0}
{"ev":"scan_done","feeds":1}</code></pre>
<p>The commands are <code>fetch</code> (optional <code>feed</code>, <code>force</code>), <code>download</code> (<code>enclosure</code>), <code>reap</code> (optional <code>dry_run</code>) and <code>status</code>.</p>

View File

@@ -0,0 +1,32 @@
<h1>The directory</h1>
<p>The directory gathers the public podcasts that iPX servers' listeners follow, and how many follow each. It never holds a file and never learns who listens.</p>
<h2>Browsing it</h2>
<p>Anyone can browse <a href="https://directory.ipxreader.app">directory.ipxreader.app</a> without signing in: the most followed, the categories, everything A to Z, and a page for each podcast with its latest episodes and its address, to subscribe to in any podcast app.</p>
<h2>Connecting your iPX</h2>
<p>In iPX, an admin opens Admin, Directory and enters the directory's address, <code>https://directory.ipxreader.app</code>, and the token its operator gave your server. Everyone on your server then has the Directory at the top of their list, and subscribes from it in one click.</p>
<p>Turn on sharing to add your server's podcasts to it. iPX sends only the podcasts' addresses and how many people on your server follow each, never who. It leaves out anything that looks private: a podcast someone marked Private, one with a login or a key in its address, and paid feeds such as Patreon's.</p>
<h2>Its API</h2>
<p>Anything the pages show, a program can read through the directory's API with a token: see the <a href="/docs/api/directory/">ipx-directory API reference</a>.</p>
<h2>Running your own</h2>
<p>The directory is in the same repository. Build it, give it a database (Postgres or SQLite) and a config file, and make a token for each server that will use it:</p>
<pre><code>docker build -f Dockerfile.directory -t ipx-directory .</code></pre>
<pre><code>services:
ipx-directory:
image: ipx-directory
container_name: ipx-directory
restart: unless-stopped
environment:
DIRECTORY_DATABASE_URL: "postgres://ipx_directory:password@db-host:5432/ipx_directory"
ports:
- "8100:8100"
volumes:
- ./directory:/config:ro # directory.toml</code></pre>
<pre><code># directory/directory.toml
bind = "0.0.0.0:8100"
database_url_env = "DIRECTORY_DATABASE_URL"</code></pre>
<pre><code>docker exec ipx-directory ipx-directory token new my-server --access rw</code></pre>
<p>That prints the token, to give to the server, and the lines to add to <code>directory.toml</code>, which keeps only its SHA-256. The directory reads the file again within a minute, so adding or revoking a token needs no restart. It checks each podcast every six hours.</p>

67
site/guides/install.html Normal file
View File

@@ -0,0 +1,67 @@
<h1>Install ipx and ipx-web</h1>
<p class="lede">iPX is two containers: <strong>ipx</strong>, which checks your podcasts, downloads them and answers the API, and <strong>ipx-web</strong>, which serves the page and passes the API on to ipx.</p>
<p>You need Docker with Compose. There are no published images yet, so you build both from the source:</p>
<pre><code>git clone https://git.sdf1.net/rays/ipx.git
cd ipx
docker build -t ipx .
docker build -f Dockerfile.web -t ipx-web .</code></pre>
<p>The repository has a sample <code>docker-compose.yml</code> that runs both, with the database in SQLite and everything ipx keeps in three folders beside the file:</p>
<pre><code>services:
ipx:
image: ipx
container_name: ipx
restart: unless-stopped
environment:
PUID: "99" # who owns the files it writes
PGID: "100"
TZ: "UTC"
ports:
- "6881:6881/tcp" # BitTorrent
- "6881:6881/udp"
volumes:
- ./config:/config # config.toml, written on first start
- ./data:/data # the database
- ./downloads:/downloads
ipx-web:
image: ipx-web
container_name: ipx-web
restart: unless-stopped
environment:
IPX_UPSTREAM: "ipx:8099"
ports:
- "8099:8080" # the page
depends_on:
- ipx</code></pre>
<pre><code>docker compose up -d</code></pre>
<p>ipx has no port of its own: everything reaches it through ipx-web, on port 8099. Open <code>http://&lt;your server&gt;:8099</code> and sign in as <strong>admin</strong> with the password <strong>ipodderx</strong>, then change it:</p>
<pre><code>echo -n 'a good password' | docker exec -i ipx ipx user passwd admin</code></pre>
<p>The password is read from what you pipe in, so it stays out of your shell's history.</p>
<h2>Adding people</h2>
<p>An admin adds people in Admin, Accounts, or from the command line:</p>
<pre><code>echo -n 'their password' | docker exec -i ipx ipx user add sam</code></pre>
<p>Each person starts with no podcasts and subscribes to their own. Two people subscribing to the same show cost nothing extra: it is fetched once and each file is kept once.</p>
<h2>Settings</h2>
<p>How often podcasts are checked, how much disk to use, how many new episodes to download and the rest are in Admin on the page, and take effect straight away. <code>/config/config.toml</code> holds only what ipx needs before it can read its database: where downloads go, BitTorrent, and who gets in.</p>
<div class="scroll"><table>
<thead><tr><th>Environment variable</th><th>What it does</th></tr></thead>
<tbody>
<tr><td><code>IPX_DATABASE_URL</code></td><td>A <code>postgres://user:password@host:5432/database</code> address, to keep everything in Postgres instead of SQLite</td></tr>
<tr><td><code>PUID</code>, <code>PGID</code></td><td>The user and group that own the files ipx writes</td></tr>
<tr><td><code>IPX_LOG_FORMAT</code></td><td><code>json</code> logs one JSON object a line, for Loki and the like</td></tr>
<tr><td><code>OTEL_EXPORTER_OTLP_ENDPOINT</code></td><td>Sends traces of requests, checks and downloads to an OpenTelemetry collector; set <code>WEB_TRACING=on</code> on ipx-web too</td></tr>
<tr><td><code>IPX_UPSTREAM</code></td><td>On ipx-web: where ipx is, <code>ipx:8099</code> by default</td></tr>
</tbody>
</table></div>
<h2>Reaching it from outside</h2>
<p>The page is plain HTTP. If anyone will reach it from outside your network, put HTTPS in front of it: a reverse proxy you already run, or a Cloudflare tunnel. People can then sign in through Cloudflare Access or Authentik instead of a password; <a href="https://git.sdf1.net/rays/ipx/src/branch/main/docs/sso.md">single sign-on</a> says how.</p>
<h2>Updating</h2>
<pre><code>git pull
docker build -t ipx .
docker build -f Dockerfile.web -t ipx-web .
docker compose up -d</code></pre>
<p>Your podcasts, accounts and downloads are in the three folders and are kept.</p>

View File

@@ -14,7 +14,7 @@
<header class="bar"> <header class="bar">
<a class="home" href="/"><img src="/logo.svg" alt="" width="36" height="36">iPX</a> <a class="home" href="/"><img src="/logo.svg" alt="" width="36" height="36">iPX</a>
<nav aria-label="Elsewhere"> <nav aria-label="Elsewhere">
<a href="/docs">Docs</a> <a href="/docs/">Docs</a>
<a href="https://directory.ipxreader.app">Directory</a> <a href="https://directory.ipxreader.app">Directory</a>
<a class="src" href="https://git.sdf1.net/rays/ipx" title="Source code on git.sdf1.net"><img src="/gitea.svg" alt="Source code" width="28" height="28"></a> <a class="src" href="https://git.sdf1.net/rays/ipx" title="Source code on git.sdf1.net"><img src="/gitea.svg" alt="Source code" width="28" height="28"></a>
</nav> </nav>

View File

@@ -90,28 +90,83 @@ h2{margin:0 0 1.5rem;color:var(--navy);font-weight:800;font-size:clamp(1.75rem,4
.also dd{margin:.45rem 0 0;max-width:34rem} .also dd{margin:.45rem 0 0;max-width:34rem}
/* Docs: one column of prose at a reading width; code and tables scroll inside themselves at /* Docs (#223): a sidebar and a column, as docs.rs lays out a crate. The sidebar is the dial's
phone width rather than the page scrolling sideways. */ sky; an endpoint's name is the scale's blue, a type's green, and each method has its own
.doc{max-width:50rem} colour, every one AA on the page. Code to read is dark, as on the home page; a declaration is
.doc .title{font-size:clamp(3.5rem,12vw,6rem);line-height:.9;letter-spacing:-.05em;margin:2rem 0 0} light, as docs.rs shows one. On a phone the sidebar follows the page instead of leading it. */
.docs{max-width:72rem;margin:0 auto;padding-inline:1.5rem;display:grid;grid-template-columns:15rem minmax(0,1fr);gap:3rem;align-items:start}
.docs main.doc{max-width:50rem;margin:0;padding:0}
.side{position:sticky;top:1rem;max-height:calc(100vh - 2rem);overflow:auto;padding:1rem .75rem;background:var(--sky);border-radius:1rem;font-size:.92rem}
.side ul{list-style:none;margin:0;padding:0}
.side li a{display:block;padding:.2rem .5rem;border-radius:.4rem;color:var(--ink);text-decoration:none;overflow-wrap:anywhere}
.side li a:hover{background:#fff}
.side a[aria-current=page]{background:#fff;font-weight:700}
.side-h{margin:1.1rem .5rem .35rem;color:var(--muted);font-size:.78rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase}
.side-h a{color:inherit}
.side-back{margin:1rem .5rem 0}
.crate{display:flex;align-items:center;gap:.6rem;padding:.25rem .5rem;color:var(--navy);text-decoration:none}
.crate img{border-radius:9px}
.crate b{display:block;font-size:1.1rem}
.ver{color:var(--muted);font-size:.85rem;font-weight:500}
.mono,.items a,.uses a,.list dt,.path,.decl,.fields dt,.item .name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.items a,.uses a{font-size:.82rem}
.items .verb{display:inline-block;min-width:3.6em}
.verb{font-weight:700}
.m-get .verb,.verb.m-get{color:#2f6aa0}
.m-post .verb,.verb.m-post{color:#1d6b44}
.m-patch .verb,.verb.m-patch{color:#875400}
.m-delete .verb,.verb.m-delete{color:#b3261e}
a.t,.item .name.t{color:#1d6b44}
.doc h1{margin:2rem 0 0;color:var(--navy);font-weight:900;font-size:clamp(2.6rem,8vw,4rem);line-height:1;letter-spacing:-.04em}
.doc h1.item{margin-top:.25rem;font-size:clamp(1.35rem,4vw,1.85rem);font-weight:500;letter-spacing:-.01em;line-height:1.3;overflow-wrap:anywhere}
.item .kind{font-family:Inter,system-ui,sans-serif;color:var(--muted)}
.item .name{font-weight:700;color:var(--scale)}
.srclink{float:right;margin-left:1rem;font:500 .9rem/2.4 Inter,system-ui,sans-serif}
.path{margin:2rem 0 0;font-size:.9rem;color:var(--muted)}
.path a{color:inherit}
.lede{margin:1.5rem 0 0;color:var(--muted);font-size:1.125rem} .lede{margin:1.5rem 0 0;color:var(--muted);font-size:1.125rem}
.toc{margin:2rem 0 0;padding:1rem 1.25rem;background:var(--sky);border-radius:.9rem} .doc h2{margin:2.5rem 0 .75rem;padding-bottom:.35rem;border-bottom:1px solid var(--rule);font-size:1.35rem;letter-spacing:-.01em}
.toc ol{margin:0;padding-left:1.25rem}
.toc li{padding-block:.15rem}
.doc section{padding-block:3.5rem 0}
.doc h2{scroll-margin-top:1rem}
.doc h3{margin:2.25rem 0 .5rem;color:var(--navy);font-weight:750;font-size:1.25rem;line-height:1.25}
.doc p{margin:.75rem 0} .doc p{margin:.75rem 0}
.summary{font-size:1.06rem}
.access{color:var(--muted)}
.access b{color:var(--ink)}
.doc code{font:500 .9em/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} .doc code{font:500 .9em/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.doc :not(pre)>code{padding:.08em .3em;background:var(--sky);border-radius:.3em;overflow-wrap:anywhere} .doc :not(pre)>code{padding:.08em .3em;background:var(--sky);border-radius:.3em;overflow-wrap:anywhere}
.doc dt>code,.doc a>code{background:none;padding:0}
.doc pre{margin:1rem 0;padding:1rem 1.15rem;background:var(--navy);color:#e6f1fa;border-radius:.8rem;overflow-x:auto} .doc pre{margin:1rem 0;padding:1rem 1.15rem;background:var(--navy);color:#e6f1fa;border-radius:.8rem;overflow-x:auto}
.doc pre code{font-size:.85rem} .doc pre code{font-size:.85rem}
.doc pre.decl{background:#fff;color:var(--ink);border:1px solid var(--rule)}
.decl a{text-decoration:none}
.scroll{margin:1rem 0;overflow-x:auto} .scroll{margin:1rem 0;overflow-x:auto}
.doc table{width:100%;border-collapse:collapse;font-size:.95rem} .doc table{width:100%;border-collapse:collapse;font-size:.95rem}
.doc th,.doc td{padding:.55rem .75rem .55rem 0;border-bottom:1px solid var(--rule);text-align:left;vertical-align:top} .doc th,.doc td{padding:.55rem .75rem .55rem 0;border-bottom:1px solid var(--rule);text-align:left;vertical-align:top}
.doc th{color:var(--navy);font-weight:700} .doc th{color:var(--navy);font-weight:700}
.doc td:first-child code{white-space:nowrap} .doc td:first-child code{white-space:nowrap}
/* An item table: the name, and the first line of what it is. */
.list{display:grid;grid-template-columns:minmax(0,max-content) minmax(0,1fr);gap:.4rem 1.5rem;margin:0}
.list dt{font-size:.88rem;overflow-wrap:anywhere}
.list dt a{text-decoration:none}
.list dt a:hover{text-decoration:underline}
.list dd{margin:0}
.fields dt{position:relative;margin-top:1rem;font-size:.92rem;overflow-wrap:anywhere}
.fields dd{margin:.25rem 0 0 1.5rem}
.anchor{position:absolute;left:-1.1rem;color:var(--muted);text-decoration:none;opacity:0}
.fields dt:hover .anchor,.anchor:focus-visible{opacity:1}
.uses{padding-left:1.25rem}
.cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem;margin:2rem 0 0}
.cards div{padding:1.1rem 1.25rem;background:#fff;border:1px solid var(--rule);border-radius:1rem}
.cards dt{font-weight:750;font-size:1.1rem}
.cards dd{margin:.35rem 0 0;color:var(--muted)}
.bar nav a[aria-current]{text-decoration:underline;text-decoration-color:var(--needle);text-decoration-thickness:2px} .bar nav a[aria-current]{text-decoration:underline;text-decoration-color:var(--needle);text-decoration-thickness:2px}
@media (max-width:800px){
.docs{grid-template-columns:minmax(0,1fr);gap:2rem;padding-inline:1rem}
.side{position:static;max-height:none;order:2}
.srclink{float:none;display:block;margin:0;line-height:1.8}
.list{grid-template-columns:minmax(0,1fr);gap:.15rem}
.list dd{margin-bottom:.6rem}
.cards{grid-template-columns:minmax(0,1fr)}
}
footer{margin-top:5rem;padding-block:1.75rem 3rem;border-top:1px solid var(--rule);color:var(--muted);font-size:.95rem} footer{margin-top:5rem;padding-block:1.75rem 3rem;border-top:1px solid var(--rule);color:var(--muted);font-size:.95rem}
footer p{margin:0} footer p{margin:0}