Compare commits
59 Commits
directory-
...
web-v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 47ad68c7da | |||
| ae58e4653a | |||
| dc3ee016db | |||
| f916ca972a | |||
| 7627af5bc8 | |||
| fbe7629420 | |||
| e882904c11 | |||
| f1a156c974 | |||
| f34423735a | |||
| 15bdce486d | |||
| 0c2c3a379e | |||
| 3e4d67fd2f | |||
| 2ed8781604 | |||
| d0ed953e84 | |||
| 4f1e7e23f6 | |||
| b18c92d1f5 | |||
| 9e5dcbb8c4 | |||
| ce485a82ac | |||
| f8371f255d | |||
| 1ae91170fd | |||
| 20f53183ac | |||
| 8d93b58deb | |||
| 0fdd609791 | |||
| 13cad18857 | |||
| 70990c70c7 | |||
| 30622fd3e1 | |||
| 1949f3e493 | |||
| 3d94bce4f5 | |||
| cd7a9b8530 | |||
| ca73b8136f | |||
| 6fde4b73cd | |||
| cdf2c98f9f | |||
| 6640ca9612 | |||
| 62a3241139 | |||
| 8f19d88fc9 | |||
| dbfe7918ac | |||
| c957db7f61 | |||
| 4e0b5eed5d | |||
| a9991545f9 | |||
| 60007d4e6a | |||
| 1471b9b9d0 | |||
| ec2b9e87b8 | |||
| af6eae9baf | |||
| 78704e40f2 | |||
| f20660a7d5 | |||
| a5e64e520d | |||
| 7ecbe8293f | |||
| 8d7a30d1f9 | |||
| cece114d98 | |||
| f00adf5da5 | |||
| d787bd26ec | |||
| 382f51a480 | |||
| 45c9f9252b | |||
| e4b505564c | |||
| 4cf568b263 | |||
| 724764c8df | |||
| cc155274e9 | |||
| 02bfa9571b | |||
| 1b137d6969 |
@@ -43,7 +43,7 @@ Start every check by seeing that all three run, since Loki cannot see a containe
|
||||
docker ps -a --filter name=iPX --filter name=ipx-web --filter name=ipx-directory --format '{{.Names}} {{.Status}}'
|
||||
```
|
||||
|
||||
All three should be `(healthy)`. Then work through ipx (the checks below), the page, the
|
||||
All three should be `Up`, with no recent restart; they have no Docker healthcheck (#218). Then work through ipx (the checks below), the page, the
|
||||
directory, Tower and the monitoring itself, in that order.
|
||||
|
||||
## ipx
|
||||
@@ -63,7 +63,7 @@ lines had no trace id, the access log's time was `ms`, and each event was logged
|
||||
| `ipx::scan` | `ev` and the event's own: `feed`, `new`, `downloaded`, `failed`, `bytes`, `msg`, `url`, `feeds`, `reason`, `from`, `to`; on a failure `error.type` and, from an HTTP error, `http.response.status_code` | the daemon's events, one line each, in words; warnings are feed and download failures |
|
||||
| `ipx::io` | `ev`, `feeds`, `pending`, `downloaded` on the `status` reply | commands arriving (`-> {...}`) and the healthcheck's answer |
|
||||
| `ipx` | message, sometimes fields | start-up, shutdown, account and config messages |
|
||||
| `ipx::client` | `ev`, `user`, `feed`, `enclosure`, `deadair`; `msg`, `src`, `line` on an error; `ms` on a timing | what the page reports from the browser (#190): `page_error`, `rejection` and `player_error` as warnings; `play_start` (ms from play to sound) and `stall` (ms waiting mid-play) as info. Lines carry the trace of the action in the page: its API requests send `traceparent`, one trace id per tap or key |
|
||||
| `ipx::client` | `ev`, `user`, `feed`, `enclosure`, `deadair`; `msg`, `src`, `line` on an error; `ms` on a timing | what the page reports from the browser (#190): `page_error`, `rejection` and `player_error` as warnings; `play_start` (ms from play to sound), `stall` (ms waiting mid-play) and `list_shown` (ms from a click to its list drawn, with `feed` the view and `filter`, #246) as info. Lines carry the trace of the action in the page: its API requests send `traceparent`, one trace id per tap or key |
|
||||
|
||||
Events (`ev`): `feed_start`, `feed_done` (new, downloaded, failed, torrents), `feed_skip` (not due,
|
||||
routine), `feed_error` (msg), `feed_moved` (from, to: a permanent redirect followed, the address
|
||||
@@ -96,7 +96,8 @@ from 2026-10-11 15:15 UTC. All gauges:
|
||||
| `ipx_torrent_slots_free` | of 2 |
|
||||
| `ipx_deadair_playing` | episodes being played through DeadAir's playlist |
|
||||
| `ipx_runtime_workers`, `ipx_runtime_tasks`, `ipx_runtime_queue` | tokio: threads, live tasks, tasks waiting for a thread |
|
||||
| `ipx_process_resident_bytes`, `ipx_process_open_fds` | memory and open files and sockets |
|
||||
| `ipx_process_resident_bytes`, `ipx_process_open_fds` | memory and open files and sockets; mimalloc gives back what is freed, so memory follows use and a rise that never comes down is a leak (#224) |
|
||||
| `ipx_feed_fetches_total`, `ipx_feed_bytes_total` | counters: feeds fetched (304s included) and the bytes of their bodies, read whole, so what scans allocate |
|
||||
| `ipx_uptime_seconds`, `ipx_build_info{version}` | since the daemon started; what runs |
|
||||
| `ipx_worker_busy`, `ipx_worker_job_seconds{kind}` | the worker's job now (`scan`, `reap`, `download`, `status`, `reload`) and for how long; the healthcheck passes while it is stuck |
|
||||
| `ipx_jobs_total{kind}`, `ipx_job_seconds_total{kind}` | counters: jobs finished, and their seconds all told |
|
||||
@@ -108,6 +109,7 @@ from 2026-10-11 15:15 UTC. All gauges:
|
||||
| `ipx_feed_retries` | feeds waiting to retry a failed name lookup |
|
||||
| `ipx_art_cache_bytes`, `ipx_art_cache_limit_bytes` | artwork kept, as of the last sweep, and the most kept: kept sits at the limit by design |
|
||||
| `ipx_art_evicted_total`, `ipx_art_requests_total{result="hit"\|"miss"}` | counters: images the sweep threw out to stay under the limit, and artwork served from disk or fetched from the publisher; many evicted at every sweep with a falling hit rate means the limit is too small |
|
||||
| `directory_process_resident_bytes`, `directory_process_open_fds`, `directory_feed_fetches_total`, `directory_feed_bytes_total`, `directory_uptime_seconds`, `directory_build_info` | the directory's, `job="ipx-directory"`, from its `metrics_bind` (production `192.168.1.130:9188`, LAN only) |
|
||||
| `ipx_feeds`, `ipx_downloads_pending`, `ipx_files_downloaded` | the library, as the healthcheck last counted it (every 30 s) |
|
||||
|
||||
### The checks
|
||||
@@ -194,8 +196,8 @@ a count says something happened, the lines and traces say why.
|
||||
$Q prom 'max(max_over_time(ipx_deadair_busy_seconds{job="ipx"}[$range]))' 24h
|
||||
```
|
||||
`up` 0 means Prometheus cannot reach the port: the container is down or `metrics_bind` is
|
||||
unset. Connections in use at the pool's max (20 on Postgres) means something is waiting for
|
||||
one: find the slow requests or scan of that time in check 3 and 5. A worker queue that only
|
||||
unset. The daemon keeps all 20 connections open on Postgres (#241), so `idle` is the pool's 20 less
|
||||
those in use; in use at 20, idle at 0, means something is waiting for one: find the slow requests or scan of that time in check 3 and 5. A worker queue that only
|
||||
grows means downloads are not keeping up, or the worker is stuck on one job. A runtime queue
|
||||
above 0 for more than a scrape or two means every thread is busy: something is blocking a
|
||||
thread or burning one. Memory, open files or tasks only climbing across a day with no restart
|
||||
@@ -213,11 +215,12 @@ a count says something happened, the lines and traces say why.
|
||||
```
|
||||
$Q metric 'sum by (ev, msg) (count_over_time({container="iPX"} |= "\"target\":\"ipx::client\"" | json | __error__="" | level="WARN" [$range]))' 7d
|
||||
$Q logs '{container="iPX"} |= "\"target\":\"ipx::client\"" | json | ev=~"play_start|stall" | line_format "{{.timestamp}} {{.user}} {{.ev}} {{.ms}}ms deadair={{.deadair}} {{.feed}}"' 24h
|
||||
$Q metric 'quantile_over_time(0.95, {container="iPX"} |= "\"ev\":\"list_shown\"" | json | __error__="" | unwrap ms [$range]) by (feed, filter)' 24h
|
||||
```
|
||||
A `player_error` is playback that failed in front of someone: `hls.js: ...` is DeadAir's
|
||||
playlist (its trace has the playlist and audio requests), `media error 4` a file the browser
|
||||
cannot play. A `page_error` or `rejection` is a bug in the page; `src` and `line` point into
|
||||
the minified `app.js`, so match the message to `web/src`. `play_start` over 2000 ms, or a
|
||||
the minified `app.js`, so match the message to `web/src`. A `list_shown` over 1000 ms is a list someone waited for: its trace says whether the API or the network held it. `play_start` over 2000 ms, or a
|
||||
`stall`, is worth a trace: was it the server, Cloudflare, or the network.
|
||||
|
||||
`docker logs --since 10m iPX 2>&1 | tail -5` shows the newest lines when Loki is behind.
|
||||
@@ -226,8 +229,8 @@ a count says something happened, the lines and traces say why.
|
||||
|
||||
Caddy logs one JSON line a request (#210), logger `http.log.access.log0`: Loki's `| json` gives
|
||||
`request_uri`, `request_method`, `request_remote_ip`, `status`, `duration` (seconds, not ms), `size`.
|
||||
The request's headers and a `?token=` are filtered out before it is written. Its healthcheck
|
||||
requests come from `127.0.0.1` every 30 s; leave them out. `/api` and `/media` pass on to ipx and
|
||||
The request's headers and a `?token=` are filtered out before it is written. Requests from
|
||||
`127.0.0.1` are local checks; leave them out. `/api` and `/media` pass on to ipx and
|
||||
are in ipx's own access log too, with ipx's time; Caddy's `duration` for them adds the hop.
|
||||
|
||||
```sh
|
||||
@@ -300,11 +303,15 @@ then; a steady stream is worth an issue, since Cloudflare can rate-limit it. A s
|
||||
`refused_private` is not 0 means an ipx server tried to send a feed that looks private: find which
|
||||
in that server's log, since the directory refuses it but the sender should not have sent it. Many
|
||||
`feed check failed` lines for one feed are the publisher's, not the directory's. Each feed is
|
||||
checked every six hours, 25 at a time, so `feeds checked` over a day should be about four times
|
||||
the feeds listed; none for over six hours means the refresher is stuck. `could not store a feed
|
||||
checked every twelve hours (#243), 25 at a time, so `feeds checked` over a day should be about
|
||||
twice the feeds listed; none for over twelve hours means the refresher is stuck. `could not store a feed
|
||||
check` or `could not read which feeds are due` is its database (`ipx_directory` on the
|
||||
`databases` project's Postgres). A config that does not load (`config changed but does not
|
||||
load`) keeps the old tokens: worth fixing before someone's new token is needed.
|
||||
load`) keeps the old tokens: worth fixing before someone's new token is needed. Weekly, `Apple
|
||||
Podcasts synced` (`shows`, `accepted`) brings Apple's charts in as the `apple-podcasts` snapshot
|
||||
(#252); `Apple Podcasts' sync failed` retries in an hour, worth an issue when it keeps failing, and
|
||||
`Apple's podcast categories are not ipx's` (`added`, `gone`) is an issue to update `CATEGORIES` in
|
||||
`crates/feed`.
|
||||
|
||||
## Tower
|
||||
|
||||
@@ -347,8 +354,9 @@ container after a deploy.
|
||||
|
||||
## Rules
|
||||
|
||||
`docker logs` and `docker exec iPX ipx ...` are fine. Do not query production's Postgres
|
||||
directly: ask the user if a question needs the database.
|
||||
`docker logs` and `docker exec iPX ipx ...` are fine. On production's Postgres, `EXPLAIN (ANALYZE,
|
||||
BUFFERS)` of any query is allowed, read only, as CLAUDE.md's "Production's database" shows; any
|
||||
other query, ask the user first.
|
||||
|
||||
## What to do with what you find
|
||||
|
||||
|
||||
60
CLAUDE.md
60
CLAUDE.md
@@ -19,7 +19,7 @@ service of the `content` project, beside audiobookshelf, immich and Plex (#208).
|
||||
| Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipx.env` beside the compose file (`/mnt/fast/arcane/projects/ipx/ipx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
|
||||
| Old database | `/mnt/user/ipx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
|
||||
| Downloads | `/mnt/user/ipx/downloads` | `/downloads` |
|
||||
| Directory | `ipx-directory` in the same project, `192.168.1.130:8100`, public at `directory.ipxreader.app` through the tunnel with no Access in front: its pages are anyone's to browse, read only (#215), and its API (`/v1`) takes a token for everything. Until 2026-10-12 it was `ipx-dir.sdf1.net` (#214). Config `/mnt/fast/appdata/ipx-directory/directory.toml` (token hashes; Tower's own token in `tower.token` beside it, and entered in ipx's Admin, Directory, which keeps it in the database's `settings`), database `ipx_directory` on the same Postgres, URL in `directory.env` beside the compose file. See [docs/directory.md](docs/directory.md) | `/config/directory.toml` |
|
||||
| Directory | `ipx-directory` in the same project, `192.168.1.130:8100`, public at `directory.ipxreader.app` through the tunnel with no Access in front: its pages are anyone's to browse, read only (#215), and its API (`/v1`) takes a token for everything. Until 2026-10-12 it was `ipx-dir.sdf1.net` (#214). Config `/mnt/fast/appdata/ipx-directory/directory.toml` (token hashes; Tower's own token in `tower.token` beside it, and entered in ipx's Admin, Directory, which keeps it in the database's `settings`, with the address ipx reaches it at: `http://ipx-directory:8100`, inside Docker, not the public address, which went out through Cloudflare and the tunnel, 170-340 ms a call where the directory answers in 16 ms), database `ipx_directory` on the same Postgres, URL in `directory.env` beside the compose file. See [docs/directory.md](docs/directory.md) | `/config/directory.toml` |
|
||||
| Home page | `ipxreader.app` (and `www`): a static page from `site/`, on the Cloudflare Pages project `ipxreader`, not on Tower. See below | |
|
||||
| Web UI | `ipx-web` (Caddy) in the same project, at `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel. It serves the page and passes `/api` and `/media` to ipx, whose own port is published nowhere; its address on `ipx_default` is pinned to `192.168.64.10` (#203) | ipx `0.0.0.0:8099`, Caddy `:8080` |
|
||||
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | Caddy passes `Cf-Access-*` only from `192.168.64.1`, the `ipx_default` gateway cloudflared arrives from (`TUNNEL_PEER`; the subnet is pinned in the compose file); ipx trusts them only from Caddy, `192.168.64.10`, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
|
||||
@@ -103,11 +103,13 @@ docker tag mirror.gcr.io/library/rust:1-slim-trixie rust:1-slim-trixie
|
||||
|
||||
Run those again now and then, or the local copies go stale.
|
||||
|
||||
The healthcheck runs `ipx status` against the control socket, so `(healthy)` in `docker ps` means
|
||||
the daemon answers there and can read its database, not just that the web port is up. The socket
|
||||
answers `status` itself instead of queuing it behind the worker's current job, so a long scan or
|
||||
download does not fail the check; it also means a worker stuck on one job would still pass. The
|
||||
container restarts on its own after a reboot.
|
||||
The containers have no Docker healthcheck (#218): Docker runs one with `exec`, which on Tower
|
||||
fails whenever root is logged out, because elogind removes `/run/user/0`, where containerd puts
|
||||
each exec's state. Whether ipx is well is Prometheus's question (`up`, the worker's job and time)
|
||||
and `ipx status` against the control socket, which says the daemon answers and can read its
|
||||
database. The socket answers `status` itself instead of queuing it behind the worker's current
|
||||
job, so a worker stuck on one job still answers it. The containers restart on their own after a
|
||||
reboot.
|
||||
|
||||
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
|
||||
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
||||
@@ -149,13 +151,19 @@ After any edit to it:
|
||||
```sh
|
||||
npx tsc -p .
|
||||
node tests/page-smoke.js
|
||||
node --test tests/page-unit.js
|
||||
```
|
||||
|
||||
The first type-checks `web/src` (loosely: `strict` is off, and `$` returns `any`). The second
|
||||
The first type-checks `web/src` in TypeScript's `strict` mode: every parameter and variable has a
|
||||
type, what the API sends is typed in `web/src/types.ts`, which mirrors the Rust structs and changes
|
||||
with them, and a value that may be null has to be checked before use. `$<T>` returns the element
|
||||
type it is given (`$<HTMLInputElement>('#nurl')`) and assumes the element is there; one that may
|
||||
not be is written `$(...)?.`, and the page check below proves the ones wired at load exist. The second
|
||||
builds the page as shipped and runs its script against a stub DOM, checking every selector it
|
||||
wires at load actually exists. That check exists because a patch once anchored on a deleted
|
||||
function, `String.replace` silently matched nothing, and the whole UI died with a
|
||||
`ReferenceError` while every server-side test passed.
|
||||
`ReferenceError` while every server-side test passed. The third loads the same built script and
|
||||
calls its helpers directly (#234): a pure function added to the page gets its check there.
|
||||
|
||||
Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first
|
||||
(`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping.
|
||||
@@ -166,12 +174,26 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
|
||||
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
|
||||
npx tsc -p . # type-checks web/src
|
||||
node tests/page-smoke.js
|
||||
node --test tests/page-unit.js # the page's own functions, called directly, in a second
|
||||
node tests/native-bridge.js # the page hands playback to a native shell
|
||||
node tests/contrast.js # every theme's palette against WCAG AA
|
||||
npx playwright test # 66 browser tests against a real daemon on fixture feeds
|
||||
node tests/load/run.js # k6: many people at once against a scratch daemon with 1,500 feeds
|
||||
```
|
||||
|
||||
**Builds share Tower with everything else, production included.** On 2026-10-16 a build of the
|
||||
directory followed in the same command by its tests' compile took Tower's load past 200 and the
|
||||
session was stopped. So:
|
||||
|
||||
* One cargo command at a time: a build, then the tests, as separate commands, never chained or
|
||||
run in the background beside another, and never two crates' builds at once. Do not tune `-j`.
|
||||
* Look at the load before and after each (`cat /proc/loadavg`). Above about 24, Tower's twelve
|
||||
threads twice over, wait for it to come down before starting another.
|
||||
* Build only the crate the change is in (`cargo build -p ipx-directory`), and run only its tests
|
||||
until the change is done; the whole workspace's once, at the end.
|
||||
* Builds and test binaries live under `target/`, a link to `/mnt/fast`; linking through
|
||||
`/mnt/user` (FUSE) once took the load to 248 on its own.
|
||||
|
||||
Things about the browser suite that have cost time:
|
||||
|
||||
* It starts **its own daemon and database** under `/tmp/ipx-ui-test`, wiped once per run. Playwright
|
||||
@@ -199,8 +221,7 @@ seeking through files, a flood of wrong passwords. Things about them:
|
||||
127.0.0.1, as production trusts Cloudflare Access's; only the sign-in test uses a password.
|
||||
* Each threshold is a budget well above what the request takes now: it is there to catch a query
|
||||
that has started asking once per feed, or writers queueing on a lock, not a slow minute.
|
||||
* `ipx status`, the Docker healthcheck, runs every second throughout, and a run fails if one takes
|
||||
the healthcheck's 5s.
|
||||
* `ipx status` runs every second throughout, and a run fails if one takes 5s.
|
||||
* One at a time: `node tests/load/run.js signin`. All four take about six minutes.
|
||||
* The daemon is on **SQLite** unless `--postgres`, which puts it in `IPX_TEST_DATABASE_URL`, in
|
||||
a schema of its own (`ipx_load`) dropped and made again each run. That URL, in `/src/.envrc`, is
|
||||
@@ -214,6 +235,25 @@ seeking through files, a flood of wrong passwords. Things about them:
|
||||
Non-trivial logic leaves one runnable check behind. Pure functions (`merge_policy`, `pick`,
|
||||
`matches_keywords`, `parse_interval`) are the easiest place to put it.
|
||||
|
||||
## Production's database
|
||||
|
||||
The owner allows `EXPLAIN (ANALYZE, BUFFERS)` of any query against production's Postgres, to see
|
||||
where a query's time goes; nothing else without asking. Run it read only, so nothing can change:
|
||||
|
||||
```sh
|
||||
U=$(grep -E '^IPX_DATABASE_URL=' /mnt/fast/arcane/projects/ipx/ipx.env | cut -d= -f2- | tr -d '"')
|
||||
psql "$U" -X -v ON_ERROR_STOP=1 <<'SQL'
|
||||
BEGIN READ ONLY;
|
||||
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
|
||||
ROLLBACK;
|
||||
SQL
|
||||
```
|
||||
|
||||
Never print the URL: it carries the password. Write the query as ipx sends it (`$1` parameters
|
||||
become `PREPARE`/`EXECUTE`, or values), for a real account. A query fast here and slow in ipx's
|
||||
trace is time outside the query: on 2026-10-15 the feed list's count took 5 ms in Postgres and 366
|
||||
ms in ipx, the difference being new connections, 80-330 ms each to open (#241).
|
||||
|
||||
## Things that are true and easy to get wrong
|
||||
|
||||
* **`enclosures.url` is globally UNIQUE.** It is the dedupe key and the reason one file serves every
|
||||
|
||||
96
Cargo.lock
generated
96
Cargo.lock
generated
@@ -17,6 +17,21 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -445,6 +460,27 @@ dependencies = [
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
@@ -620,9 +656,12 @@ version = "0.4.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
"zstd",
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1820,7 +1859,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "ipx"
|
||||
version = "1.0.0"
|
||||
version = "1.3.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
@@ -1829,6 +1868,7 @@ dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"ipx-directory",
|
||||
"ipx-feed",
|
||||
@@ -1856,13 +1896,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ipx-directory"
|
||||
version = "1.0.0"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"ipx-feed",
|
||||
"mimalloc",
|
||||
"opentelemetry",
|
||||
"opentelemetry-otlp",
|
||||
"opentelemetry_sdk",
|
||||
@@ -1875,6 +1918,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"toml",
|
||||
"tower",
|
||||
"tower-http 0.7.1",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
@@ -2102,6 +2146,15 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.23"
|
||||
@@ -2541,6 +2594,15 @@ dependencies = [
|
||||
"rapidhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -2791,6 +2853,7 @@ version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "699a67345e21962a231955b9059a157e428b219fa5df8efe11f08346fb23a344"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"http",
|
||||
"httpdate",
|
||||
"opentelemetry",
|
||||
@@ -4631,6 +4694,7 @@ version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.13.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -5438,3 +5502,31 @@ name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "057cfd910cfac363a0ada849592624b4c9ff2e10bef504c3433810d78ed96f93"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "8.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdd44c6a7284e91f3717755b24315a302edd9153a01f753c3cba3d765e8eafac"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.1.1+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
@@ -15,18 +15,20 @@ anyhow = "1.0.104"
|
||||
argon2 = "0.6.0"
|
||||
atom_syndication = "0.12.10"
|
||||
axum = "0.8.9"
|
||||
flate2 = "1.1.10"
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
||||
clap = { version = "4.6.6", features = ["derive"] }
|
||||
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
||||
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
|
||||
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
||||
opentelemetry = { version = "0.33", default-features = false, features = ["trace"] }
|
||||
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client"] }
|
||||
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client", "gzip-http"] }
|
||||
opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace"] }
|
||||
opml = "1.1.6"
|
||||
mimalloc = "0.1.52"
|
||||
percent-encoding = "2.3.2"
|
||||
quick-xml = { version = "0.42.0", features = ["escape-html"] }
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "brotli", "zstd", "stream", "json", "charset", "system-proxy"] }
|
||||
rss = "2.1.1"
|
||||
sea-orm = { version = "2.0.3", default-features = false, features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros", "with-json", "sqlite-use-returning-for-3_35"] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
@@ -35,7 +37,7 @@ sha2 = "0.10.9"
|
||||
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal", "process"] }
|
||||
toml = "1.1.5"
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
tower-http = { version = "0.7.1", features = ["fs"] }
|
||||
tower-http = { version = "0.7.1", features = ["fs", "compression-br", "compression-gzip", "compression-zstd", "decompression-gzip"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-opentelemetry = { version = "0.34", default-features = false }
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
|
||||
|
||||
@@ -20,6 +20,8 @@ RUN mkdir -p crates/ipx/src crates/feed/src crates/directory/src \
|
||||
&& rm -rf crates/ipx/src crates/feed/src crates/directory/src
|
||||
|
||||
COPY crates ./crates
|
||||
# ipx builds the directory crate in, whose public pages serve these (#215).
|
||||
COPY web/logo.svg web/logo-dark.svg web/favicon.png ./web/
|
||||
# cargo skips a rebuild if mtimes look untouched; make sure it does not.
|
||||
RUN touch crates/ipx/src/main.rs crates/feed/src/lib.rs && cargo build --release --locked -p ipx
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ COPY web ./web
|
||||
RUN node web/build.mjs /dist
|
||||
|
||||
FROM caddy:2
|
||||
# caddy:2's Alpine ships curl, which nothing here runs (no healthcheck since #218): out, so a
|
||||
# curl flaw such as CVE-2026-92392 has nothing to reach in a container facing the internet (#237).
|
||||
RUN apk del --no-cache curl
|
||||
COPY web/Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --from=build /dist /srv
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -10,6 +10,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.0] - 2026-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Answers and pages go out compressed, brotli, zstd or gzip as the caller takes them: the feed list ipx asks for was 603 KB of JSON. A snapshot may come gzipped, `/metrics` answers gzipped, and with `OTEL_EXPORTER_OTLP_COMPRESSION=gzip` traces go to the collector compressed (#253).
|
||||
- With `apple_country` set, Apple Podcasts' top charts for that country (the top 100 and the top 50 of each category) come into the directory every week, beside what servers share, without counting as subscribers; and a warning says when Apple's categories no longer match ipx's (#252).
|
||||
|
||||
### Changed
|
||||
|
||||
- Each feed is checked every twelve hours instead of six (#243).
|
||||
- Feeds are fetched accepting brotli and zstd as well as gzip (#244).
|
||||
- On Postgres the server keeps all ten of its database connections open, so a request does not wait for a new one (#241).
|
||||
|
||||
## [1.1.1] - 2026-10-15
|
||||
|
||||
### Changed
|
||||
|
||||
- The directory checks five feeds at a time, so a publisher that does not answer no longer holds up the rest of its round (#227).
|
||||
- A feed's server that takes 10 s to connect, or sends nothing for 15 s, is given up on (#230).
|
||||
|
||||
### Security
|
||||
|
||||
- A failed feed check is logged without its address's query or password (#238).
|
||||
|
||||
## [1.1.0] - 2026-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- With `metrics_bind` set, the directory serves Prometheus metrics on a port of its own: memory, open files, and the feeds it has fetched and their bytes. A feed check's trace says how large the feed was (#224).
|
||||
|
||||
### Changed
|
||||
|
||||
- The directory uses mimalloc instead of glibc's allocator, so it gives back memory it has freed instead of holding over a gigabyte while idle (#224).
|
||||
- The example in docs/directory.md has no healthcheck; `ipx-directory health` is still there to run by hand (#218).
|
||||
|
||||
### Security
|
||||
|
||||
- A feed check's trace keeps the feed's address without its query or password (#225).
|
||||
|
||||
## [1.0.0] - 2026-10-12
|
||||
|
||||
### Added
|
||||
@@ -19,5 +58,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- `ipx-directory`, a directory of public podcasts that ipx servers share: each sends the public feeds its people subscribe to, and any server with a token browses them, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205).
|
||||
- Traces: a span a request, joining the trace of the ipx server that called, and a trace a refresher round with a span a feed (#213).
|
||||
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.0.0...main
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.2.0...main
|
||||
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.1...directory-v1.2.0
|
||||
[1.1.1]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.0...directory-v1.1.1
|
||||
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.0.0...directory-v1.1.0
|
||||
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/directory-v1.0.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ipx-directory"
|
||||
version = "1.0.0"
|
||||
version = "1.2.0"
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
@@ -12,10 +12,12 @@ ammonia.workspace = true
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
futures-util.workspace = true
|
||||
ipx-feed.workspace = true
|
||||
opentelemetry.workspace = true
|
||||
opentelemetry-otlp.workspace = true
|
||||
opentelemetry_sdk.workspace = true
|
||||
mimalloc.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
reqwest.workspace = true
|
||||
sea-orm.workspace = true
|
||||
@@ -24,10 +26,12 @@ serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-opentelemetry.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
flate2.workspace = true
|
||||
tower.workspace = true
|
||||
|
||||
273
crates/directory/src/apple.rs
Normal file
273
crates/directory/src/apple.rs
Normal file
@@ -0,0 +1,273 @@
|
||||
//! Apple Podcasts, weekly (#252): its top charts for a country into the directory, beside what
|
||||
//! ipx servers share, and its categories checked against the copy ipx keeps (`ipx_feed`).
|
||||
//!
|
||||
//! The charts are a snapshot of their own, under `INSTANCE`, with no subscribers: Apple's shows
|
||||
//! are fetched, categorised and listed like any other feed, and the most subscribed stay what this
|
||||
//! directory's people subscribe to. Each show's RSS address comes from Apple's lookup API.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use tracing::Instrument;
|
||||
|
||||
use crate::{Shared, SnapshotEntry};
|
||||
|
||||
/// What the charts are filed under, as a server's snapshot is under its token's name.
|
||||
pub const INSTANCE: &str = "apple-podcasts";
|
||||
/// How often, and how long to wait after a sync fails before trying again.
|
||||
const EVERY: i64 = 7 * 86_400;
|
||||
const RETRY: Duration = Duration::from_secs(3600);
|
||||
/// The top this many overall, and of each category: about a thousand shows once they overlap.
|
||||
const TOP: u32 = 100;
|
||||
const TOP_OF_EACH: u32 = 50;
|
||||
/// Apple asks for no more than about twenty calls a minute.
|
||||
const PAUSE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Syncs when a week has passed since the last, forever.
|
||||
pub async fn run(dir: Shared, country: String) {
|
||||
let client = match reqwest::Client::builder()
|
||||
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => return tracing::error!(error = %e, "Apple Podcasts' sync could not start"),
|
||||
};
|
||||
loop {
|
||||
let last = dir.store.synced_at(INSTANCE).await.unwrap_or(None).unwrap_or(0);
|
||||
let wait = last + EVERY - crate::store::now();
|
||||
if wait > 0 {
|
||||
// Woken hourly at most, so a database restored from before the last sync is noticed.
|
||||
tokio::time::sleep(Duration::from_secs((wait as u64).min(3600))).await;
|
||||
continue;
|
||||
}
|
||||
let span = tracing::info_span!("apple_sync", country = %country);
|
||||
match sync(&dir, &client, &country).instrument(span).await {
|
||||
Ok(()) => {
|
||||
let _ = dir.store.set_synced(INSTANCE, crate::store::now()).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "Apple Podcasts' sync failed; trying again in an hour");
|
||||
tokio::time::sleep(RETRY).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync(dir: &Shared, client: &reqwest::Client, country: &str) -> Result<()> {
|
||||
let genres: Genres = get(client, "https://itunes.apple.com/WebObjects/MZStoreServices.woa/ws/genres?id=26").await?;
|
||||
let theirs = categories(&genres);
|
||||
let (added, gone) = differ(&theirs, ipx_feed::categories());
|
||||
if !added.is_empty() || !gone.is_empty() {
|
||||
tracing::warn!(
|
||||
added = %added.join(", "), gone = %gone.join(", "),
|
||||
"Apple's podcast categories are not ipx's: the copy in crates/feed (CATEGORIES) wants updating"
|
||||
);
|
||||
}
|
||||
|
||||
// The top overall, then the top of each category, each show once.
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
let charts = std::iter::once((None, TOP)).chain(genre_ids(&genres).into_iter().map(|g| (Some(g), TOP_OF_EACH)));
|
||||
for (genre, n) in charts {
|
||||
let url = match &genre {
|
||||
Some(g) => format!("https://itunes.apple.com/{country}/rss/toppodcasts/limit={n}/genre={g}/json"),
|
||||
None => format!("https://itunes.apple.com/{country}/rss/toppodcasts/limit={n}/json"),
|
||||
};
|
||||
match get::<Chart>(client, &url).await {
|
||||
Ok(chart) => ids.extend(chart_ids(&chart)),
|
||||
// One category's chart missing is no reason to keep last week's whole list.
|
||||
Err(e) => tracing::info!(genre = ?genre, error = %format!("{e:#}"), "an Apple chart did not load"),
|
||||
}
|
||||
tokio::time::sleep(PAUSE).await;
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
ids.retain(|id| seen.insert(id.clone()));
|
||||
anyhow::ensure!(!ids.is_empty(), "no chart had a show in it");
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for batch in ids.chunks(100) {
|
||||
let url = format!("https://itunes.apple.com/lookup?entity=podcast&id={}", batch.join(","));
|
||||
let found: Lookup = get(client, &url).await?;
|
||||
entries.extend(lookup_entries(&found));
|
||||
tokio::time::sleep(PAUSE).await;
|
||||
}
|
||||
let result = dir.store.snapshot(INSTANCE, &entries).await?;
|
||||
tracing::info!(
|
||||
shows = ids.len(), accepted = result.accepted, refused_private = result.refused_private,
|
||||
refused_blocked = result.refused_blocked, refused_invalid = result.refused_invalid,
|
||||
"Apple Podcasts synced"
|
||||
);
|
||||
// Fetched now, not when the refresher next looks.
|
||||
dir.wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get<T: serde::de::DeserializeOwned>(client: &reqwest::Client, url: &str) -> Result<T> {
|
||||
let span = tracing::info_span!("apple_get", url = %url);
|
||||
async {
|
||||
let resp = client.get(url).send().await.context("asking Apple")?.error_for_status()?;
|
||||
resp.json::<T>().await.context("reading Apple's answer")
|
||||
}
|
||||
.instrument(span)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Apple's genres under Podcasts (26): each category's id and name, and its subcategories.
|
||||
#[derive(Deserialize)]
|
||||
struct Genres(BTreeMap<String, Genre>);
|
||||
#[derive(Deserialize)]
|
||||
struct Genre {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
subgenres: BTreeMap<String, Genre>,
|
||||
}
|
||||
|
||||
fn categories(g: &Genres) -> BTreeMap<String, Vec<String>> {
|
||||
g.0.get("26")
|
||||
.map(|podcasts| {
|
||||
podcasts.subgenres.values().map(|c| (c.name.clone(), c.subgenres.values().map(|s| s.name.clone()).collect())).collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn genre_ids(g: &Genres) -> Vec<String> {
|
||||
g.0.get("26").map(|p| p.subgenres.keys().cloned().collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// What Apple has that ipx's list lacks, and what ipx's has that Apple no longer does, as
|
||||
/// "Category" or "Category / Subcategory".
|
||||
fn differ(theirs: &BTreeMap<String, Vec<String>>, ours: &[(&str, &[&str])]) -> (Vec<String>, Vec<String>) {
|
||||
let flat = |c: &str, subs: &mut dyn Iterator<Item = String>| {
|
||||
std::iter::once(c.to_string()).chain(subs.map(|s| format!("{c} / {s}"))).collect::<Vec<_>>()
|
||||
};
|
||||
let theirs: std::collections::BTreeSet<String> =
|
||||
theirs.iter().flat_map(|(c, subs)| flat(c, &mut subs.iter().cloned())).collect();
|
||||
let ours: std::collections::BTreeSet<String> =
|
||||
ours.iter().flat_map(|(c, subs)| flat(c, &mut subs.iter().map(|s| s.to_string()))).collect();
|
||||
(theirs.difference(&ours).cloned().collect(), ours.difference(&theirs).cloned().collect())
|
||||
}
|
||||
|
||||
/// A chart, as Apple's iTunes RSS gives it: the shows' Apple ids, in order.
|
||||
#[derive(Deserialize)]
|
||||
struct Chart {
|
||||
feed: ChartFeed,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ChartFeed {
|
||||
// A chart of one show is that show, not a list of one.
|
||||
#[serde(default, deserialize_with = "one_or_many")]
|
||||
entry: Vec<ChartEntry>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ChartEntry {
|
||||
id: ChartId,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ChartId {
|
||||
attributes: ChartIdAttributes,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ChartIdAttributes {
|
||||
#[serde(rename = "im:id")]
|
||||
id: String,
|
||||
}
|
||||
|
||||
fn one_or_many<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<ChartEntry>, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum OneOrMany {
|
||||
One(ChartEntry),
|
||||
Many(Vec<ChartEntry>),
|
||||
}
|
||||
Ok(match OneOrMany::deserialize(d)? {
|
||||
OneOrMany::One(e) => vec![e],
|
||||
OneOrMany::Many(v) => v,
|
||||
})
|
||||
}
|
||||
|
||||
fn chart_ids(c: &Chart) -> Vec<String> {
|
||||
c.feed.entry.iter().map(|e| e.id.attributes.id.clone()).collect()
|
||||
}
|
||||
|
||||
/// Apple's lookup answer: each show's RSS address and the category Apple files it under.
|
||||
#[derive(Deserialize)]
|
||||
struct Lookup {
|
||||
#[serde(default)]
|
||||
results: Vec<LookupResult>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct LookupResult {
|
||||
#[serde(rename = "feedUrl")]
|
||||
feed_url: Option<String>,
|
||||
#[serde(rename = "primaryGenreName")]
|
||||
genre: Option<String>,
|
||||
}
|
||||
|
||||
fn lookup_entries(l: &Lookup) -> Vec<SnapshotEntry> {
|
||||
l.results
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let url = r.feed_url.as_deref()?.trim();
|
||||
(!url.is_empty()).then(|| SnapshotEntry {
|
||||
url: url.to_owned(),
|
||||
subscribers: 0,
|
||||
// "Podcasts" is the root Apple files a show under when it gives no category.
|
||||
category: r.genre.clone().filter(|g| g != "Podcasts"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apples_categories_are_read_and_compared_with_ipxs() {
|
||||
let genres: Genres = serde_json::from_str(
|
||||
r#"{"26": {"name": "Podcasts", "subgenres": {
|
||||
"1301": {"name": "Arts", "subgenres": {"1482": {"name": "Books"}, "1999": {"name": "Murals"}}},
|
||||
"1310": {"name": "Music"}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(genre_ids(&genres), ["1301", "1310"]);
|
||||
let ours: &[(&str, &[&str])] = &[("Arts", &["Books", "Design"]), ("Music", &[]), ("Hobbies", &[])];
|
||||
let (added, gone) = differ(&categories(&genres), ours);
|
||||
assert_eq!(added, ["Arts / Murals"]);
|
||||
assert_eq!(gone, ["Arts / Design", "Hobbies"]);
|
||||
// ipx's own copy is Apple's whole list, each category once.
|
||||
let mine = ipx_feed::categories();
|
||||
assert_eq!(mine.len(), 19);
|
||||
assert!(mine.iter().any(|(c, subs)| *c == "News" && subs.contains(&"Daily News")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chart_gives_its_shows_in_order_and_a_lookup_their_addresses() {
|
||||
let chart: Chart = serde_json::from_str(
|
||||
r#"{"feed": {"entry": [
|
||||
{"id": {"label": "https://x", "attributes": {"im:id": "1200361736"}}},
|
||||
{"id": {"label": "https://y", "attributes": {"im:id": "1028908750"}}}]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(chart_ids(&chart), ["1200361736", "1028908750"]);
|
||||
let one: Chart = serde_json::from_str(r#"{"feed": {"entry": {"id": {"attributes": {"im:id": "7"}}}}}"#).unwrap();
|
||||
assert_eq!(chart_ids(&one), ["7"], "a chart of one");
|
||||
let empty: Chart = serde_json::from_str(r#"{"feed": {}}"#).unwrap();
|
||||
assert!(chart_ids(&empty).is_empty());
|
||||
|
||||
let found: Lookup = serde_json::from_str(
|
||||
r#"{"resultCount": 3, "results": [
|
||||
{"trackName": "The Daily", "feedUrl": "https://feeds.simplecast.com/Sl5CSM3S", "primaryGenreName": "Daily News"},
|
||||
{"trackName": "No feed"},
|
||||
{"trackName": "Rooted", "feedUrl": "https://example.com/rss", "primaryGenreName": "Podcasts"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let entries = lookup_entries(&found);
|
||||
assert_eq!(entries.len(), 2, "a show Apple has no feed for is left out");
|
||||
assert_eq!((entries[0].subscribers, entries[0].category.as_deref()), (0, Some("Daily News")));
|
||||
assert_eq!(entries[1].category, None, "Podcasts is no category");
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,15 @@ pub struct Config {
|
||||
/// The environment variable holding the database URL, so the file holds no secret.
|
||||
#[serde(default = "default_database_url_env")]
|
||||
pub database_url_env: String,
|
||||
/// Where to serve /metrics for Prometheus (#224), apart from `bind`, which the tunnel
|
||||
/// publishes: `0.0.0.0:9188`. Empty serves none.
|
||||
#[serde(default)]
|
||||
pub metrics_bind: String,
|
||||
/// Apple Podcasts' top charts for this country (its two letters: `ca`, `us`) come into the
|
||||
/// directory each week, and Apple's categories are checked against ipx's (#252). Empty, or
|
||||
/// left out, does neither.
|
||||
#[serde(default)]
|
||||
pub apple_country: String,
|
||||
#[serde(default)]
|
||||
pub tokens: Vec<TokenCfg>,
|
||||
}
|
||||
@@ -77,6 +86,14 @@ impl Config {
|
||||
if !seen.insert(t.name.as_str()) {
|
||||
bail!("two tokens are named {:?}", t.name);
|
||||
}
|
||||
// Apple's charts are filed under it; a server of that name would replace them.
|
||||
if t.name == crate::apple::INSTANCE {
|
||||
bail!("{:?} is the name Apple Podcasts' charts are kept under; give the token another", t.name);
|
||||
}
|
||||
}
|
||||
let c = cfg.apple_country.trim();
|
||||
if !c.is_empty() && !(c.len() == 2 && c.chars().all(|c| c.is_ascii_alphabetic())) {
|
||||
bail!("apple_country is a country's two letters, such as \"ca\" or \"us\", not {c:?}");
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
//! The types here are the wire format, shared with ipx, which asks the directory on its
|
||||
//! people's behalf.
|
||||
|
||||
pub mod apple;
|
||||
pub mod config;
|
||||
pub mod metrics;
|
||||
pub mod otel;
|
||||
mod site;
|
||||
pub mod store;
|
||||
@@ -116,8 +118,8 @@ pub struct Blocked {
|
||||
pub const MAX_SNAPSHOT: usize = 20_000;
|
||||
|
||||
/// How often each feed is fetched again. Metadata changes slowly, and every server subscribed
|
||||
/// to a feed fetches it itself anyway.
|
||||
pub const REFRESH_EVERY: i64 = 6 * 3600;
|
||||
/// to a feed fetches it itself anyway: every six hours was 3.9 GB of feeds a day (#243).
|
||||
pub const REFRESH_EVERY: i64 = 12 * 3600;
|
||||
|
||||
pub struct Directory {
|
||||
pub store: Store,
|
||||
@@ -133,6 +135,8 @@ impl Directory {
|
||||
pub fn new(store: Store, tokens: Vec<Token>) -> Result<Shared, reqwest::Error> {
|
||||
// No redirects: ipx_feed::fetch follows them itself, so it sees each hop.
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.read_timeout(ipx_feed::FEED_SILENCE)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
|
||||
.build()?;
|
||||
@@ -174,7 +178,7 @@ impl Directory {
|
||||
Err(e) => Refreshed::Failed(format!("{e:#}")),
|
||||
};
|
||||
if let Refreshed::Failed(e) = &got {
|
||||
tracing::info!(feed = %due.id, error = %e, "feed check failed");
|
||||
tracing::info!(feed = %due.id, error = %ipx_feed::redact_text(e), "feed check failed");
|
||||
}
|
||||
// ponytail: a feed that moved for good is still asked for at its old address, which
|
||||
// redirects; follow the move when a feed's id can change without losing its counts.
|
||||
@@ -192,12 +196,17 @@ pub async fn refresher(dir: Shared) {
|
||||
let round = tracing::info_span!("refresh", feeds = due.len());
|
||||
tracing::Instrument::instrument(
|
||||
async {
|
||||
for d in &due {
|
||||
// A few at a time: in turn, two publishers timing out held a round
|
||||
// for a minute and more (#227).
|
||||
futures_util::StreamExt::for_each_concurrent(futures_util::stream::iter(&due), 5, |d| {
|
||||
let check = tracing::info_span!("feed", feed = %d.id);
|
||||
if let Err(e) = tracing::Instrument::instrument(dir.refresh(d), check).await {
|
||||
tracing::warn!(feed = %d.id, error = %format!("{e:#}"), "could not store a feed check");
|
||||
async {
|
||||
if let Err(e) = tracing::Instrument::instrument(dir.refresh(d), check).await {
|
||||
tracing::warn!(feed = %d.id, error = %format!("{e:#}"), "could not store a feed check");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
// One line a round, for the dashboard's refresher panel (#210); failures have their own.
|
||||
tracing::info!(checked = due.len(), "feeds checked");
|
||||
},
|
||||
@@ -259,6 +268,12 @@ pub fn router(dir: Shared) -> Router {
|
||||
.merge(v1)
|
||||
// Anyone may browse, no token, read only (#215).
|
||||
.merge(site::router())
|
||||
// Brotli, zstd or gzip as the caller takes them: the feed list went to ipx as 603 KB of
|
||||
// JSON, most of a Directory page's time (#253). Inside the access log, which records what
|
||||
// was sent.
|
||||
.layer(tower_http::compression::CompressionLayer::new().br(true).zstd(true).gzip(true))
|
||||
// And takes a request's body gzipped: ipx sends its snapshot so, about 100 KB as JSON.
|
||||
.layer(tower_http::decompression::RequestDecompressionLayer::new().gzip(true))
|
||||
.layer(axum::middleware::from_fn(access_log))
|
||||
.with_state(dir)
|
||||
}
|
||||
@@ -468,6 +483,45 @@ mod tests {
|
||||
(status, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_snapshot_may_come_gzipped() {
|
||||
use std::io::Write as _;
|
||||
let (app, _) = app().await;
|
||||
let json = serde_json::json!([{"url": "https://example.com/feed.xml", "subscribers": 2}]).to_string();
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
gz.write_all(json.as_bytes()).unwrap();
|
||||
// As ipx sends it (#253).
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/snapshot")
|
||||
.header("authorization", "Bearer secret-rw")
|
||||
.header("content-type", "application/json")
|
||||
.header("content-encoding", "gzip")
|
||||
.body(Body::from(gz.finish().unwrap()))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let got: serde_json::Value = serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap()).unwrap();
|
||||
assert_eq!(got["accepted"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn answers_come_compressed_as_the_caller_asks() {
|
||||
let (app, _) = app().await;
|
||||
for (asked, sent) in [("br", Some("br")), ("zstd", Some("zstd")), ("gzip", Some("gzip")), ("identity", None)] {
|
||||
// A page, not an empty feed list: under 32 bytes is sent as it is.
|
||||
let req = axum::http::Request::builder()
|
||||
.uri("/")
|
||||
.header("accept-encoding", asked)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let got = resp.headers().get("content-encoding").map(|v| v.to_str().unwrap().to_owned());
|
||||
assert_eq!(got.as_deref(), sent, "asked for {asked} (#253)");
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_page(app: &Router, path: &str) -> (u16, String, Option<String>) {
|
||||
let req = axum::http::Request::builder().uri(path).body(Body::empty()).unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
@@ -25,8 +25,7 @@ enum Cmd {
|
||||
#[command(subcommand)]
|
||||
cmd: TokenCmd,
|
||||
},
|
||||
/// Check that the directory on this machine answers and can read its database: the
|
||||
/// container's healthcheck.
|
||||
/// Check that the directory on this machine answers and can read its database.
|
||||
Health,
|
||||
}
|
||||
|
||||
@@ -83,7 +82,7 @@ async fn serve(path: &std::path::Path) -> Result<()> {
|
||||
.with(json.then(|| tracing_subscriber::fmt::layer().json().flatten_event(true).with_writer(std::io::stderr).with_filter(filter())))
|
||||
.with(otel.as_ref().map(|p| {
|
||||
use opentelemetry::trace::TracerProvider;
|
||||
tracing_opentelemetry::layer().with_tracer(p.tracer("ipx-directory")).with_filter(tracing_subscriber::EnvFilter::new("ipx_directory=info"))
|
||||
tracing_opentelemetry::layer().with_tracer(p.tracer("ipx-directory")).with_filter(tracing_subscriber::EnvFilter::new("ipx_directory=info,ipx_feed=info"))
|
||||
}))
|
||||
.init();
|
||||
|
||||
@@ -93,13 +92,23 @@ async fn serve(path: &std::path::Path) -> Result<()> {
|
||||
.ok()
|
||||
.filter(|u| !u.trim().is_empty())
|
||||
.with_context(|| format!("{} is not set: the directory needs a database URL", cfg.database_url_env))?;
|
||||
let store = Store::open(&url).await.context("opening the database")?;
|
||||
let store = Store::open(&url, true).await.context("opening the database")?;
|
||||
let dir = Directory::new(store, vec![])?;
|
||||
let dropped = dir.set_tokens(tokens).await?;
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&cfg.bind).await.with_context(|| format!("listening on {}", cfg.bind))?;
|
||||
tracing::info!(version = env!("CARGO_PKG_VERSION"), bind = %cfg.bind, tokens = cfg.tokens.len(), dropped, "directory started");
|
||||
if !cfg.metrics_bind.is_empty() {
|
||||
let metrics = tokio::net::TcpListener::bind(&cfg.metrics_bind).await.with_context(|| format!("listening for metrics on {}", cfg.metrics_bind))?;
|
||||
tracing::info!(bind = %cfg.metrics_bind, "serving metrics for Prometheus at /metrics");
|
||||
tokio::spawn(ipx_directory::metrics::serve(metrics));
|
||||
}
|
||||
tokio::spawn(ipx_directory::refresher(dir.clone()));
|
||||
let country = cfg.apple_country.trim().to_ascii_lowercase();
|
||||
if !country.is_empty() {
|
||||
tracing::info!(country = %country, "Apple Podcasts' charts are synced weekly");
|
||||
tokio::spawn(ipx_directory::apple::run(dir.clone(), country));
|
||||
}
|
||||
tokio::spawn(ipx_directory::reloader(dir.clone(), path.to_owned()));
|
||||
axum::serve(listener, ipx_directory::router(dir))
|
||||
.with_graceful_shutdown(async {
|
||||
|
||||
69
crates/directory/src/metrics.rs
Normal file
69
crates/directory/src/metrics.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
//! Memory, for ipx and the directory both (#224), and the directory's own /metrics.
|
||||
//!
|
||||
//! mimalloc is the allocator of every binary that builds this crate in, ipx included: with
|
||||
//! glibc's, ipx kept the 500-850 MB a night of scans had needed until it restarted, and the
|
||||
//! directory sat at 1.25 GiB doing nothing, each thread's arena holding what it had freed.
|
||||
//! mimalloc gives freed pages back, so resident memory follows what is in use: a spike that
|
||||
//! does not come down again is a leak. Not jemalloc, whose upstream was archived in 2025.
|
||||
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
|
||||
#[global_allocator]
|
||||
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
|
||||
/// The process's resident memory in bytes and its open files, from /proc; None elsewhere.
|
||||
pub fn process() -> (Option<f64>, Option<f64>) {
|
||||
let rss = std::fs::read_to_string("/proc/self/status").ok().and_then(|s| {
|
||||
let kb = s.lines().find_map(|l| l.strip_prefix("VmRSS:"))?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()?;
|
||||
Some(kb * 1024.0)
|
||||
});
|
||||
let fds = std::fs::read_dir("/proc/self/fd").ok().map(|d| d.count() as f64);
|
||||
(rss, fds)
|
||||
}
|
||||
|
||||
/// The directory's /metrics, in Prometheus's text format: memory, open files, and the feeds it
|
||||
/// has fetched. Nothing from the database, so a scrape costs nothing and answers while it is down.
|
||||
fn render(started: std::time::Instant) -> String {
|
||||
let mut out = String::new();
|
||||
let mut gauge = |name: &str, kind: &str, help: &str, labels: &str, v: f64| {
|
||||
out.push_str(&format!("# HELP {name} {help}\n# TYPE {name} {kind}\n{name}{labels} {v}\n"));
|
||||
};
|
||||
let (rss, fds) = process();
|
||||
if let Some(v) = rss {
|
||||
gauge("directory_process_resident_bytes", "gauge", "Memory the process holds.", "", v);
|
||||
}
|
||||
if let Some(v) = fds {
|
||||
gauge("directory_process_open_fds", "gauge", "Files and sockets the process has open.", "", v);
|
||||
}
|
||||
gauge("directory_feed_fetches_total", "counter", "Feeds fetched, answered 304 included.", "", ipx_feed::FETCHES.load(Relaxed) as f64);
|
||||
gauge("directory_feed_bytes_total", "counter", "Bytes of feed bodies fetched.", "", ipx_feed::FETCHED_BYTES.load(Relaxed) as f64);
|
||||
gauge("directory_uptime_seconds", "gauge", "Seconds since the directory started.", "", started.elapsed().as_secs() as f64);
|
||||
gauge("directory_build_info", "gauge", "The version running, as a label.", &format!("{{version=\"{}\"}}", env!("CARGO_PKG_VERSION")), 1.0);
|
||||
out
|
||||
}
|
||||
|
||||
/// Serves /metrics on a listener of its own: the public port is behind the tunnel with no
|
||||
/// Access in front, and these are for the LAN.
|
||||
pub async fn serve(listener: tokio::net::TcpListener) {
|
||||
let started = std::time::Instant::now();
|
||||
let app = axum::Router::new().route(
|
||||
"/metrics",
|
||||
axum::routing::get(move || async move {
|
||||
([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(started))
|
||||
}),
|
||||
)
|
||||
// Prometheus asks for gzip (#253).
|
||||
.layer(tower_http::compression::CompressionLayer::new().gzip(true));
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
tracing::error!(error = %e, "metrics stopped");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn metrics_say_memory_and_feeds() {
|
||||
let text = super::render(std::time::Instant::now());
|
||||
assert!(text.contains("directory_process_resident_bytes ") && text.contains("directory_feed_bytes_total "), "{text}");
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,11 @@ const SCHEMA: &[&str] = &[
|
||||
PRIMARY KEY (instance, feed_id)
|
||||
)",
|
||||
"CREATE INDEX IF NOT EXISTS snapshots_feed ON snapshots (feed_id)",
|
||||
// When each periodic job last finished: Apple's weekly sync (#252).
|
||||
"CREATE TABLE IF NOT EXISTS syncs (
|
||||
name TEXT PRIMARY KEY,
|
||||
at BIGINT NOT NULL
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS blocked (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT,
|
||||
@@ -99,7 +104,7 @@ impl Store {
|
||||
pub async fn scratch() -> Self {
|
||||
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let Some(url) = std::env::var("IPX_TEST_DATABASE_URL").ok().filter(|u| !u.is_empty()) else {
|
||||
return Self::open("sqlite::memory:").await.unwrap();
|
||||
return Self::open("sqlite::memory:", false).await.unwrap();
|
||||
};
|
||||
let (pid, n) = (std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
|
||||
// A bare connection: Store::open would make the tables in the public schema, and the
|
||||
@@ -117,10 +122,11 @@ impl Store {
|
||||
let schema = format!("ipxd_{pid}_{n}");
|
||||
admin.db.execute_unprepared(&format!("CREATE SCHEMA {schema}")).await.unwrap();
|
||||
let sep = if url.contains('?') { '&' } else { '?' };
|
||||
Self::open(&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning")).await.unwrap()
|
||||
Self::open(&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"), false).await.unwrap()
|
||||
}
|
||||
|
||||
pub async fn open(url: &str) -> Result<Self> {
|
||||
/// `keep_open` for the server, which keeps its whole pool connected.
|
||||
pub async fn open(url: &str, keep_open: bool) -> Result<Self> {
|
||||
let mut url = url.to_owned();
|
||||
if url.starts_with("postgres") && !url.contains("options=") {
|
||||
// Postgres sends a notice for every CREATE ... IF NOT EXISTS on something that exists;
|
||||
@@ -134,6 +140,11 @@ impl Store {
|
||||
if url.contains(":memory:") {
|
||||
opts.max_connections(1);
|
||||
}
|
||||
// The server keeps every connection open, as ipx's daemon does: a Postgres connection takes
|
||||
// 80-330 ms to open (#241). Not a test, which would hold ten for one question.
|
||||
if keep_open && url.starts_with("postgres") {
|
||||
opts.max_connections(10).min_connections(10);
|
||||
}
|
||||
let db = sea_orm::Database::connect(opts).await?;
|
||||
for sql in SCHEMA {
|
||||
db.execute_unprepared(sql).await?;
|
||||
@@ -158,6 +169,23 @@ impl Store {
|
||||
Ok(self.db.query_all_raw(self.stmt(sql, values)).await?)
|
||||
}
|
||||
|
||||
/// When the job `name` last finished, Unix seconds.
|
||||
pub async fn synced_at(&self, name: &str) -> Result<Option<i64>> {
|
||||
Ok(match self.rows("SELECT at FROM syncs WHERE name = $1", vec![name.into()]).await?.first() {
|
||||
Some(r) => Some(r.try_get("", "at")?),
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_synced(&self, name: &str, at: i64) -> Result<()> {
|
||||
self.exec(
|
||||
"INSERT INTO syncs (name, at) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET at = excluded.at",
|
||||
vec![name.into(), at.into()],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn ping(&self) -> Result<()> {
|
||||
self.rows("SELECT 1", vec![]).await?;
|
||||
Ok(())
|
||||
@@ -235,7 +263,8 @@ impl Store {
|
||||
.iter()
|
||||
.map(|r| r.try_get("", "instance"))
|
||||
.collect::<Result<_, _>>()?;
|
||||
for name in present.iter().filter(|n| !names.contains(n)) {
|
||||
// Apple's charts are no token's, and stay until the next sync replaces them.
|
||||
for name in present.iter().filter(|n| !names.contains(n) && n.as_str() != crate::apple::INSTANCE) {
|
||||
dropped += self.drop_instance(name).await?;
|
||||
}
|
||||
Ok(dropped)
|
||||
|
||||
@@ -61,13 +61,57 @@ pub enum Fetched {
|
||||
/// downloads, which are not, have no such limit.
|
||||
pub const FEED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
/// The longest a feed's server may send nothing, for the clients that fetch feeds to set: one
|
||||
/// that took the connection and never answered ran the whole 30 s, every scan it was in (#230).
|
||||
/// No feed that answered took over 8 s in a day of traces; one that keeps sending, however
|
||||
/// slowly, still has until FEED_TIMEOUT.
|
||||
pub const FEED_SILENCE: std::time::Duration = std::time::Duration::from_secs(15);
|
||||
|
||||
/// Feeds fetched and the bytes of their bodies, since the process started, for /metrics to set
|
||||
/// beside memory (#224): a body is read whole, so a night of large feeds is a night of large
|
||||
/// allocations.
|
||||
pub static FETCHES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
pub static FETCHED_BYTES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// Conditional GET, following redirects itself: `client` must follow none (`feed_client`), so
|
||||
/// each hop is seen. The second value is where the feed now is when every hop said so for good
|
||||
/// (301 or 308): a publisher that moved its feed, which the catalogue should follow rather
|
||||
/// than be redirected on every read. A temporary redirect (302, 307) moves nothing.
|
||||
/// `system-proxy` reads http_proxy/https_proxy.
|
||||
/// `auth` is a username and password, sent only to the feed's own host.
|
||||
#[tracing::instrument(skip_all, fields(url = %feed_url))]
|
||||
/// An address as a trace may keep it: without its query or user and password, where a private
|
||||
/// feed carries its credential (Patreon's `?auth=`), which Tempo kept for anyone reading it (#225).
|
||||
/// shortcut: a token in the path (`/feed/<token>.xml`) stays; strip paths too if a feed does that.
|
||||
pub fn redact(url: &str) -> String {
|
||||
match url::Url::parse(url) {
|
||||
Ok(mut u) => {
|
||||
u.set_query(None);
|
||||
u.set_fragment(None);
|
||||
let _ = u.set_username("");
|
||||
let _ = u.set_password(None);
|
||||
u.to_string()
|
||||
}
|
||||
// Not an address ipx could fetch; keep what comes before anything that could be a query.
|
||||
Err(_) => url.split(['?', '#', '@']).next().unwrap_or_default().to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text with each address in it redacted, for what is logged and sent to every open page: an
|
||||
/// error from reqwest names the whole address it fetched, a private feed's key with it (#238).
|
||||
pub fn redact_text(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(i) = rest.find("http://").into_iter().chain(rest.find("https://")).min() {
|
||||
out.push_str(&rest[..i]);
|
||||
let end = rest[i..].find(|c: char| c.is_whitespace() || "()<>\"'".contains(c)).map_or(rest.len(), |n| i + n);
|
||||
out.push_str(&redact(&rest[i..end]));
|
||||
rest = &rest[end..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(url = %redact(feed_url), http.response.body.size))]
|
||||
pub async fn fetch(
|
||||
client: &reqwest::Client,
|
||||
feed_url: &str,
|
||||
@@ -106,6 +150,7 @@ pub async fn fetch(
|
||||
continue;
|
||||
}
|
||||
let moved = (permanent && url != start).then(|| url.to_string());
|
||||
FETCHES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if status == StatusCode::NOT_MODIFIED {
|
||||
return Ok((Fetched::NotModified, moved));
|
||||
}
|
||||
@@ -119,6 +164,8 @@ pub async fn fetch(
|
||||
let etag = header(ETAG);
|
||||
let last_modified = header(LAST_MODIFIED);
|
||||
let bytes = resp.bytes().await.context("reading body")?.to_vec();
|
||||
tracing::Span::current().record("http.response.body.size", bytes.len());
|
||||
FETCHED_BYTES.fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
|
||||
return Ok((Fetched::Body { bytes, etag, last_modified }, moved));
|
||||
}
|
||||
// Worded as reqwest worded it, which failure_kind reads as a redirect loop.
|
||||
@@ -364,6 +411,7 @@ fn parse_patreon_shows(json: &[u8]) -> Result<(Option<String>, Vec<(String, Stri
|
||||
}
|
||||
|
||||
/// RSS first, then Atom -- the same split the original made on `parsedFeed.version`.
|
||||
#[tracing::instrument(skip_all, fields(bytes = bytes.len()))]
|
||||
pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> {
|
||||
match rss::Channel::read_from(bytes) {
|
||||
Ok(ch) => Ok(from_rss(ch, bytes)),
|
||||
@@ -1072,6 +1120,11 @@ const ALIASES: &[(&str, &str)] =
|
||||
/// Where a category sits in Apple's list, as (category, subcategory), so the Directory browses a
|
||||
/// category and then its subcategories as Apple's does (#118). A podcast's own is stored as its
|
||||
/// subcategory, and one an admin typed that is not Apple's stands as a category of its own.
|
||||
/// Apple's categories as ipx has them, for the directory to check against Apple's own (#252).
|
||||
pub fn categories() -> &'static [(&'static str, &'static [&'static str])] {
|
||||
CATEGORIES
|
||||
}
|
||||
|
||||
pub fn category_path(name: &str) -> (String, Option<String>) {
|
||||
// A feed's own text, which can end in a stray comma: "Soccer," was a category of its own.
|
||||
let name = name.trim().trim_end_matches([',', ';', '.']).trim_end();
|
||||
@@ -1087,6 +1140,9 @@ pub fn category_path(name: &str) -> (String, Option<String>) {
|
||||
(name.to_string(), None)
|
||||
}
|
||||
|
||||
/// TypeSafe's System One endpoint, where Jev answers questions about a piece of state.
|
||||
pub const JEV_URL: &str = "https://api.typesafe.ai/v1/systemone";
|
||||
|
||||
/// A Directory category for a feed that names none of its own, which most blogs do not (#117):
|
||||
/// TypeSafe's Jev picks one of Apple's categories or subcategories from the feed's title and
|
||||
/// latest items, the subcategory being what a podcast's own category is stored as. The likeliest
|
||||
@@ -1111,7 +1167,7 @@ pub async fn classify(client: &reqwest::Client, key: &str, feed: &ParsedFeed) ->
|
||||
}},
|
||||
});
|
||||
let answer: serde_json::Value = client
|
||||
.post("https://api.typesafe.ai/v1/systemone")
|
||||
.post(JEV_URL)
|
||||
.bearer_auth(key)
|
||||
.json(&body)
|
||||
.timeout(FEED_TIMEOUT)
|
||||
@@ -1163,6 +1219,21 @@ const PAID_HOSTS: &[&str] =
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn redact_drops_what_carries_a_credential() {
|
||||
assert_eq!(super::redact("https://www.patreon.com/rss/x?auth=SECRET&show=1"), "https://www.patreon.com/rss/x");
|
||||
assert_eq!(super::redact("https://me:pw@example.com/feed.xml#top"), "https://example.com/feed.xml");
|
||||
assert_eq!(super::redact("https://example.com/feed.xml"), "https://example.com/feed.xml");
|
||||
assert_eq!(super::redact("not a url?token=SECRET"), "not a url");
|
||||
// As reqwest words a failure, and with two addresses and none.
|
||||
assert_eq!(
|
||||
super::redact_text("connecting: error sending request for url (https://www.patreon.com/rss/x?auth=SECRET&show=1): operation timed out"),
|
||||
"connecting: error sending request for url (https://www.patreon.com/rss/x): operation timed out"
|
||||
);
|
||||
assert_eq!(super::redact_text("from http://a/f?k=1 to https://u:p@b/g"), "from http://a/f to https://b/g");
|
||||
assert_eq!(super::redact_text("HTTP 404 Not Found"), "HTTP 404 Not Found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_carrying_someones_access_looks_private() {
|
||||
let private = super::url_looks_private;
|
||||
|
||||
@@ -12,6 +12,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.3.0] - 2026-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Signals: a feed of your own, picked out of the noise of everything you subscribe to, holding the items TypeSafe's Jev says are about the subject you give it: a name, words, and a sentence of your own if they are not enough, from the last 30 days and every new one, judged on your own TypeSafe key, each with an icon of its own if you like, which Jev can pick on the server's TypeSafe key (`POST /api/signals/icon`) (`/api/signals`, and `typesafe_key` on `/api/me`) (#254).
|
||||
- With `OTEL_EXPORTER_OTLP_COMPRESSION=gzip`, traces go to the collector compressed; `/metrics` answers gzipped, and a snapshot goes to the directory gzipped (#253).
|
||||
- A Directory request's trace shows the call to the directory whole, with the size of its answer, and the steps that match it against your subscriptions (#250).
|
||||
- The page's report of how long a list took to show after a click (`list_shown`, with the view and filter) is logged under that click's trace (#246).
|
||||
|
||||
### Changed
|
||||
|
||||
- A feed that sends the same body again, as Patreon and Substack do whatever they are asked, is taken as unchanged instead of read again, and, without a schedule of its own, checked less often the longer it stays the same, up to every four hours, back on schedule when it changes (#242).
|
||||
- Feeds are fetched accepting brotli and zstd as well as gzip (#244).
|
||||
- On Postgres the daemon keeps all twenty of its database connections open, so a page load no longer waits for new ones, 80-330 ms each: the feed list and item lists took 0.3-1.6 s now and then, where their queries take 5-30 ms (#241).
|
||||
- Currently Listening and the Pinned filter answer in a few milliseconds: they read every item on the server, 1.3 s for Currently Listening (#249).
|
||||
|
||||
## [1.2.0] - 2026-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- `scan_done` on the event stream says which feed or folder the scan was limited to and whether someone asked for it (`only`, `forced`), so a page can tell the end of its own scan from a scheduled one's (#233).
|
||||
|
||||
### Changed
|
||||
|
||||
- Check every feed looks up the site icons of feeds without artwork a few at a time, alongside the fetches, instead of one after another (#228).
|
||||
- A feed's server that sends nothing for 15 s is given up on, instead of holding a scan for the full 30 s; a feed that keeps sending still has 30 s (#230).
|
||||
- A scan's trace names the feed each fetch is for (#229).
|
||||
|
||||
### Security
|
||||
|
||||
- A failure's words, and the addresses in an event, go to the log and to every open page without their query or password, where a private feed keeps its key: a failed fetch of a Patreon feed logged its `auth` key (#238).
|
||||
|
||||
## [1.1.0] - 2026-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- `/metrics` counts the feeds fetched and the bytes of their bodies (`ipx_feed_fetches_total`, `ipx_feed_bytes_total`), and a feed's trace says how large its body was (#224).
|
||||
|
||||
### Changed
|
||||
|
||||
- ipx uses mimalloc instead of glibc's allocator, which gives freed memory back to the system: with glibc's, the memory a night of scans needed stayed held until a restart (#224).
|
||||
- `docker-compose.yml` has no healthchecks: Docker runs them with exec, which can fail on the host while ipx is well. The library's counts in `/metrics` are kept every 30 s by the daemon itself (#218).
|
||||
|
||||
### Security
|
||||
|
||||
- Traces no longer keep a feed's or file's address with its query or password, where private feeds carry their credentials, such as Patreon's `auth` (#225).
|
||||
|
||||
## [1.0.0] - 2026-10-12
|
||||
|
||||
### Added
|
||||
@@ -801,7 +848,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
||||
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.0.0...main
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.3.0...main
|
||||
[1.3.0]: https://git.sdf1.net/rays/ipx/compare/v1.2.0...v1.3.0
|
||||
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/v1.1.0...v1.2.0
|
||||
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/v1.0.0...v1.1.0
|
||||
[1.0.0]: https://git.sdf1.net/rays/ipx/compare/v0.12.0...v1.0.0
|
||||
[0.12.0]: https://git.sdf1.net/rays/ipx/compare/v0.11.0...v0.12.0
|
||||
[0.11.0]: https://git.sdf1.net/rays/ipx/compare/v0.10.3...v0.11.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ipx"
|
||||
version = "1.0.0"
|
||||
version = "1.3.0"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -9,6 +9,7 @@ ipx-directory.workspace = true
|
||||
ammonia.workspace = true
|
||||
anyhow.workspace = true
|
||||
argon2.workspace = true
|
||||
flate2.workspace = true
|
||||
atom_syndication.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! per-feed .ipxd plists, history.dat and qmcache.dat.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crate::entity::{api_tokens, blocklists, catalogue, deadair_prefs, enclosures, entries, feeds, hidden, sessions, settings, subscriptions, users};
|
||||
use crate::entity::{api_tokens, blocklists, catalogue, deadair_prefs, enclosures, entries, feeds, hidden, sessions, settings, signal_verdicts, signals, subscriptions, users};
|
||||
use sea_orm::sea_query::{Expr, Func};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
|
||||
@@ -63,6 +63,8 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
|
||||
schema.create_table_from_entity(deadair_plans::Entity),
|
||||
schema.create_table_from_entity(deadair_prefs::Entity),
|
||||
schema.create_table_from_entity(deadair_saved::Entity),
|
||||
schema.create_table_from_entity(signals::Entity),
|
||||
schema.create_table_from_entity(signal_verdicts::Entity),
|
||||
] {
|
||||
orm.execute(table.if_not_exists()).await.context("creating the schema")?;
|
||||
}
|
||||
@@ -95,6 +97,26 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
|
||||
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
||||
}
|
||||
}
|
||||
if orm.execute_unprepared("SELECT body_hash FROM feeds LIMIT 0").await.is_err() {
|
||||
for sql in ["ALTER TABLE feeds ADD COLUMN body_hash TEXT", "ALTER TABLE feeds ADD COLUMN same_since BIGINT"] {
|
||||
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
||||
}
|
||||
}
|
||||
if orm.execute_unprepared("SELECT typesafe_key FROM users LIMIT 0").await.is_err() {
|
||||
let sql = "ALTER TABLE users ADD COLUMN typesafe_key TEXT";
|
||||
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
||||
}
|
||||
if orm.execute_unprepared("SELECT description FROM signals LIMIT 0").await.is_err() {
|
||||
// Every verdict so far answered the old question, any one keyword on its own, which put
|
||||
// a game's remaster in AI Development (#259): all of them are asked again.
|
||||
for sql in ["ALTER TABLE signals ADD COLUMN description TEXT", "DELETE FROM signal_verdicts"] {
|
||||
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
||||
}
|
||||
}
|
||||
if orm.execute_unprepared("SELECT icon FROM signals LIMIT 0").await.is_err() {
|
||||
let sql = "ALTER TABLE signals ADD COLUMN icon TEXT";
|
||||
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
||||
}
|
||||
if orm.execute_unprepared("SELECT pause_ms FROM deadair_prefs LIMIT 0").await.is_err() {
|
||||
let sql = "ALTER TABLE deadair_prefs ADD COLUMN pause_ms BIGINT";
|
||||
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
||||
@@ -149,7 +171,7 @@ fn is_postgres(location: &str) -> bool {
|
||||
|
||||
/// sqlx's defaults for SQLite are what ipx wants, foreign keys on and a five-second wait for a
|
||||
/// lock, which is what rusqlite was set to, but for one.
|
||||
async fn connect(location: &str) -> Result<sea_orm::DatabaseConnection> {
|
||||
async fn connect(location: &str, keep_open: bool) -> Result<sea_orm::DatabaseConnection> {
|
||||
let mut opts = sea_orm::ConnectOptions::new(url_for(location));
|
||||
opts.sqlx_logging(false);
|
||||
// NORMAL, SQLite's own advice for WAL: synced at checkpoints, not after every commit. FULL,
|
||||
@@ -172,6 +194,13 @@ async fn connect(location: &str) -> Result<sea_orm::DatabaseConnection> {
|
||||
// past. Postgres gets twenty, where sqlx's ten filled in bursts: a page load asks for dozens
|
||||
// of covers at once, each with a lookup (#200). The server allows 100, shared with other apps.
|
||||
opts.max_connections(if is_postgres(location) { 20 } else { 8 });
|
||||
// The daemon keeps every connection open (#241): a Postgres connection takes 80-330 ms to
|
||||
// open, its password exchange most of it, and a page load asked for more than were idle, so
|
||||
// the feed list and item list waited on new ones, 300-900 ms where their queries took 5-30.
|
||||
// Not a command such as `ipx status`, nor a test, which would hold twenty for one question.
|
||||
if keep_open && is_postgres(location) {
|
||||
opts.min_connections(20);
|
||||
}
|
||||
sea_orm::Database::connect(opts)
|
||||
.await
|
||||
.with_context(|| format!("opening {}", redact(location)))
|
||||
@@ -294,19 +323,22 @@ pub struct FeedSummary {
|
||||
pub last_error: Option<String>,
|
||||
/// When this run of failures began; see the `error_since` column.
|
||||
pub error_since: Option<i64>,
|
||||
/// Since when the feed has sent the same body (#242).
|
||||
pub same_since: Option<i64>,
|
||||
pub entries: i64,
|
||||
pub downloaded: i64,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Opens the database at `location` (see `location()`): a postgres:// URL, or a SQLite file.
|
||||
pub async fn open(location: &str) -> Result<Self> {
|
||||
/// `keep_open` for the daemon, which keeps its whole pool connected.
|
||||
pub async fn open(location: &str, keep_open: bool) -> Result<Self> {
|
||||
if !is_postgres(location)
|
||||
&& let Some(dir) = Path::new(location).parent()
|
||||
{
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||
}
|
||||
let orm = connect(location).await?;
|
||||
let orm = connect(location, keep_open).await?;
|
||||
if !is_postgres(location) {
|
||||
// WAL, so the healthcheck's `ipx status` reads while the daemon writes. It is a
|
||||
// setting of the file, kept once made, and making it takes a lock that cannot wait out
|
||||
@@ -342,7 +374,7 @@ impl Db {
|
||||
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let pid = std::process::id();
|
||||
if let Some(url) = std::env::var("IPX_TEST_DATABASE_URL").ok().filter(|u| !u.is_empty()) {
|
||||
let admin = connect(&url).await?;
|
||||
let admin = connect(&url, false).await?;
|
||||
// Schemas an earlier run left behind: anything of ours not made by this process.
|
||||
let old = admin
|
||||
.query_all_raw(Statement::from_string(
|
||||
@@ -362,13 +394,13 @@ impl Db {
|
||||
// Every connection in the pool starts in it, so each test sees only its own tables.
|
||||
let sep = if url.contains('?') { '&' } else { '?' };
|
||||
let orm =
|
||||
connect(&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"))
|
||||
connect(&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"), false)
|
||||
.await?;
|
||||
create_missing(&orm).await?;
|
||||
return Ok(Self { orm, tmp: None });
|
||||
}
|
||||
let path = std::env::temp_dir().join(format!("ipx-test-{pid}-{n}.db"));
|
||||
let orm = connect(&path.display().to_string()).await?;
|
||||
let orm = connect(&path.display().to_string(), false).await?;
|
||||
// As Db::open has it, so the tests' connections share the file as the daemon's do.
|
||||
orm.execute_unprepared("PRAGMA journal_mode = WAL").await?;
|
||||
create_missing(&orm).await?;
|
||||
@@ -499,6 +531,7 @@ impl Db {
|
||||
last_error: f.last_error,
|
||||
orphaned: f.orphaned,
|
||||
error_since: f.error_since,
|
||||
same_since: f.same_since,
|
||||
category: f.category,
|
||||
},
|
||||
ttl_mins: f.ttl_mins.map(|t| t.max(0) as u64),
|
||||
@@ -530,6 +563,9 @@ pub struct HttpState {
|
||||
pub ttl_mins: Option<u64>,
|
||||
/// When the feed's current run of failures began, for backing off (`due_after`).
|
||||
pub error_since: Option<i64>,
|
||||
/// The last body's hash, and since when it has been that body (#242).
|
||||
pub body_hash: Option<String>,
|
||||
pub same_since: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<feeds::Model> for HttpState {
|
||||
@@ -540,6 +576,8 @@ impl From<feeds::Model> for HttpState {
|
||||
last_checked: f.last_checked,
|
||||
ttl_mins: f.ttl_mins.map(|t| t.max(0) as u64),
|
||||
error_since: f.error_since,
|
||||
body_hash: f.body_hash,
|
||||
same_since: f.same_since,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -606,6 +644,16 @@ impl Db {
|
||||
}
|
||||
|
||||
/// 304, or any other poll that produced no new data: only the clock moves.
|
||||
/// A body read whole that was not the last one: its hash, and that it changed now (#242).
|
||||
pub async fn set_body(&self, feed_id: &str, hash: &str) -> Result<()> {
|
||||
self.exec(
|
||||
"UPDATE feeds SET body_hash = $2, same_since = $3 WHERE id = $1",
|
||||
vec![feed_id.into(), hash.into(), now().into()],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn touch_feed(&self, feed_id: &str, url: &str) -> Result<()> {
|
||||
self.exec(
|
||||
"INSERT INTO feeds (id, url, last_checked) VALUES ($1, $2, $3)
|
||||
@@ -1094,12 +1142,19 @@ fn entries_from(
|
||||
filter: Filter,
|
||||
search: Option<&str>,
|
||||
guid: Option<&str>,
|
||||
signal: Option<i64>,
|
||||
) -> String {
|
||||
let user = a.p(user_id);
|
||||
let mut scope = match feed_id {
|
||||
Some(f) => format!("e.feed_id = {}", a.p(f)),
|
||||
None => format!("e.feed_id IN (SELECT feed_id FROM subscriptions WHERE user_id = {user})"),
|
||||
};
|
||||
if let Some(id) = signal {
|
||||
scope = format!(
|
||||
"{scope} AND EXISTS (SELECT 1 FROM signal_verdicts v WHERE v.signal_id = {} AND v.feed_id = e.feed_id AND v.guid = e.guid AND v.hit)",
|
||||
a.p(id)
|
||||
);
|
||||
}
|
||||
if let Some(g) = guid {
|
||||
scope = format!("{scope} AND e.guid = {}", a.p(g));
|
||||
}
|
||||
@@ -1188,14 +1243,18 @@ impl Filter {
|
||||
// true and false, not 1 and 0: the columns are booleans on Postgres, and SQLite
|
||||
// reads true and false as 1 and 0.
|
||||
Self::All => "true",
|
||||
// An item with no row of state is unread, so this one keeps its coalesce.
|
||||
Self::Unread => "NOT coalesce(s.read, false)",
|
||||
Self::Flagged => "coalesce(s.flagged, false)",
|
||||
// Pinned and in progress need a row of state, said without coalesce so the database
|
||||
// can tell, and starts from this person's few rows instead of every item: Currently
|
||||
// Listening read all 574,000, 970 ms, where this takes 2 (#249).
|
||||
Self::Flagged => "s.flagged",
|
||||
Self::Downloaded => {
|
||||
"EXISTS (SELECT 1 FROM enclosures x
|
||||
WHERE x.feed_id = e.feed_id AND x.guid = e.guid AND x.path IS NOT NULL)"
|
||||
}
|
||||
Self::InProgress => {
|
||||
"coalesce(s.position, 0) > 5
|
||||
"s.position > 5
|
||||
AND (coalesce(s.duration, e.duration, 0) = 0
|
||||
OR s.position * 10 < coalesce(s.duration, e.duration) * 9)"
|
||||
}
|
||||
@@ -1239,14 +1298,37 @@ impl Db {
|
||||
limit: i64,
|
||||
order: &str,
|
||||
) -> Result<Vec<EntryRow>> {
|
||||
self.entries_where(user_id, feed_id, filter, search, None, offset, limit, order).await
|
||||
self.entries_where(user_id, feed_id, filter, search, None, None, offset, limit, order).await
|
||||
}
|
||||
|
||||
/// A Signal's page of entries, as `entries_in` gives All Subscriptions'. The caller has made
|
||||
/// sure the Signal is this person's.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn signal_entries(
|
||||
&self,
|
||||
user_id: i64,
|
||||
signal: i64,
|
||||
filter: Filter,
|
||||
search: Option<&str>,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
order: &str,
|
||||
) -> Result<Vec<EntryRow>> {
|
||||
self.entries_where(user_id, None, filter, search, None, Some(signal), offset, limit, order).await
|
||||
}
|
||||
|
||||
pub async fn signal_count(&self, user_id: i64, signal: i64, filter: Filter, search: Option<&str>) -> Result<i64> {
|
||||
let mut a = Args::default();
|
||||
let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, None, filter, search, None, Some(signal)));
|
||||
let r = self.rows(&sql, a.0).await?;
|
||||
Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?)
|
||||
}
|
||||
|
||||
/// One entry as `entries_in` gives it: what the player asks for again when the clock it
|
||||
/// plays in changes (DeadAir, #171).
|
||||
pub async fn entry(&self, user_id: i64, feed_id: &str, guid: &str) -> Result<Option<EntryRow>> {
|
||||
let order = order_sql("published", "desc");
|
||||
Ok(self.entries_where(user_id, Some(feed_id), Filter::All, None, Some(guid), 0, 1, &order).await?.pop())
|
||||
Ok(self.entries_where(user_id, Some(feed_id), Filter::All, None, Some(guid), None, 0, 1, &order).await?.pop())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1257,12 +1339,13 @@ impl Db {
|
||||
filter: Filter,
|
||||
search: Option<&str>,
|
||||
guid: Option<&str>,
|
||||
signal: Option<i64>,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
order: &str,
|
||||
) -> Result<Vec<EntryRow>> {
|
||||
let mut a = Args::default();
|
||||
let from = entries_from(&mut a, user_id, feed_id, filter, search, guid);
|
||||
let from = entries_from(&mut a, user_id, feed_id, filter, search, guid, signal);
|
||||
// The page's keys first, then its rows: the sort then needs only `entries_by_time`, not
|
||||
// every item's row, and the show notes are read for the page alone (#188).
|
||||
let (limit, offset) = (a.p(limit), a.p(offset));
|
||||
@@ -1328,7 +1411,7 @@ impl Db {
|
||||
search: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let mut a = Args::default();
|
||||
let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feed_id, filter, search, None));
|
||||
let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feed_id, filter, search, None, None));
|
||||
let r = self.rows(&sql, a.0).await?;
|
||||
Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?)
|
||||
}
|
||||
@@ -2461,6 +2544,239 @@ impl Db {
|
||||
}
|
||||
|
||||
/// Unix seconds. Everything time-shaped in the DB is stored this way.
|
||||
/// A Signal as its owner's sidebar lists it (#254).
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct SignalRow {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub keywords: Vec<String>,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub items: i64,
|
||||
pub unread: i64,
|
||||
}
|
||||
|
||||
/// What a Signal is changed to: each field None to leave it as it is.
|
||||
#[derive(Default)]
|
||||
pub struct SignalChange<'a> {
|
||||
pub name: Option<&'a str>,
|
||||
pub words: Option<&'a [String]>,
|
||||
pub description: Option<Option<&'a str>>,
|
||||
pub icon: Option<Option<&'a str>>,
|
||||
}
|
||||
|
||||
/// One Signal's question about an item: what it is called, its words, and its owner's sentence.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Ask {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub keywords: Vec<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// An item still to be put to Jev for someone, with each of their Signals that has not been
|
||||
/// asked about it.
|
||||
#[derive(Debug)]
|
||||
pub struct Unjudged {
|
||||
pub user_id: i64,
|
||||
pub key: String,
|
||||
pub feed_id: String,
|
||||
pub guid: String,
|
||||
pub feed_title: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub signals: Vec<Ask>,
|
||||
}
|
||||
|
||||
/// How far back a Signal looks when it is made: a month of items, not every back catalogue.
|
||||
pub const SIGNAL_DAYS: i64 = 30;
|
||||
|
||||
impl Db {
|
||||
pub async fn signals(&self, user_id: i64) -> Result<Vec<SignalRow>> {
|
||||
self.signal_rows(user_id, None).await
|
||||
}
|
||||
|
||||
/// One of this person's Signals: None for anyone else's, as for one that does not exist.
|
||||
pub async fn signal(&self, user_id: i64, id: i64) -> Result<Option<SignalRow>> {
|
||||
Ok(self.signal_rows(user_id, Some(id)).await?.pop())
|
||||
}
|
||||
|
||||
async fn signal_rows(&self, user_id: i64, id: Option<i64>) -> Result<Vec<SignalRow>> {
|
||||
let mut a = Args::default();
|
||||
let user = a.p(user_id);
|
||||
let one = id.map(|i| format!("AND g.id = {}", a.p(i))).unwrap_or_default();
|
||||
// The items it shows, and of those the unread: what its list counts, by the same rules.
|
||||
let hits = |unread: &str| {
|
||||
format!(
|
||||
"(SELECT count(*) FROM signal_verdicts v
|
||||
JOIN entries e ON e.feed_id = v.feed_id AND e.guid = v.guid
|
||||
JOIN subscriptions s ON s.user_id = g.user_id AND s.feed_id = v.feed_id
|
||||
LEFT JOIN entry_state st ON st.user_id = g.user_id AND st.feed_id = v.feed_id AND st.guid = v.guid
|
||||
WHERE v.signal_id = g.id AND v.hit AND {unread} AND {})",
|
||||
not_hidden(&user)
|
||||
)
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT g.id, g.name, g.keywords, g.description, g.icon, {} AS items, {} AS unread
|
||||
FROM signals g WHERE g.user_id = {user} {one} ORDER BY lower(g.name), g.id",
|
||||
hits("true"),
|
||||
hits("NOT coalesce(st.read, false)")
|
||||
);
|
||||
self.rows(&sql, a.0)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
Ok(SignalRow {
|
||||
id: r.try_get("", "id")?,
|
||||
name: r.try_get("", "name")?,
|
||||
keywords: keywords(r.try_get("", "keywords")?).unwrap_or_default(),
|
||||
description: r.try_get("", "description")?,
|
||||
icon: r.try_get("", "icon")?,
|
||||
items: r.try_get("", "items")?,
|
||||
unread: r.try_get("", "unread")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn add_signal(&self, user_id: i64, name: &str, words: &[String], description: Option<&str>) -> Result<i64> {
|
||||
let m = signals::ActiveModel {
|
||||
user_id: Set(user_id),
|
||||
name: Set(name.to_owned()),
|
||||
keywords: Set(serde_json::to_string(words)?),
|
||||
description: Set(description.map(str::to_owned)),
|
||||
created: Set(now()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&self.orm)
|
||||
.await?;
|
||||
Ok(m.id)
|
||||
}
|
||||
|
||||
/// Changes a Signal. A new name, words or sentence puts every item to Jev again, since what it
|
||||
/// said was about the old question (#259); a new icon does not. False for a Signal that is not
|
||||
/// this person's.
|
||||
pub async fn set_signal(&self, user_id: i64, id: i64, change: SignalChange<'_>) -> Result<bool> {
|
||||
let SignalChange { name, words, description, icon } = change;
|
||||
if self.signal(user_id, id).await?.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut m = signals::ActiveModel { id: Set(id), ..Default::default() };
|
||||
if let Some(n) = name {
|
||||
m.name = Set(n.to_owned());
|
||||
}
|
||||
if let Some(w) = words {
|
||||
m.keywords = Set(serde_json::to_string(w)?);
|
||||
}
|
||||
if let Some(d) = description {
|
||||
m.description = Set(d.map(str::to_owned));
|
||||
}
|
||||
if let Some(i) = icon {
|
||||
m.icon = Set(i.map(str::to_owned));
|
||||
}
|
||||
if name.is_some() || words.is_some() || description.is_some() {
|
||||
signal_verdicts::Entity::delete_many().filter(signal_verdicts::Column::SignalId.eq(id)).exec(&self.orm).await?;
|
||||
}
|
||||
if name.is_some() || words.is_some() || description.is_some() || icon.is_some() {
|
||||
m.update(&self.orm).await?;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn remove_signal(&self, user_id: i64, id: i64) -> Result<bool> {
|
||||
if self.signal(user_id, id).await?.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
// Not left to the cascade: SQLite enforces foreign keys only when asked to.
|
||||
signal_verdicts::Entity::delete_many().filter(signal_verdicts::Column::SignalId.eq(id)).exec(&self.orm).await?;
|
||||
signals::Entity::delete_by_id(id).exec(&self.orm).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn typesafe_key_set(&self, user_id: i64) -> Result<bool> {
|
||||
Ok(users::Entity::find_by_id(user_id).one(&self.orm).await?.is_some_and(|u| u.typesafe_key.is_some()))
|
||||
}
|
||||
|
||||
pub async fn set_typesafe_key(&self, user_id: i64, key: Option<&str>) -> Result<()> {
|
||||
self.update_user(user_id, users::ActiveModel { typesafe_key: Set(key.map(str::to_owned)), ..Default::default() })
|
||||
.await
|
||||
}
|
||||
|
||||
/// Up to `limit` items, newest first, that someone with a key has a Signal not yet asked
|
||||
/// about: from the feeds they subscribe to, the last `SIGNAL_DAYS`, and not hidden from them.
|
||||
/// `skip` are people whose key TypeSafe has refused.
|
||||
pub async fn unjudged(&self, skip: &[i64], limit: i64) -> Result<Vec<Unjudged>> {
|
||||
let mut a = Args::default();
|
||||
let since = a.p(now() - SIGNAL_DAYS * 86_400);
|
||||
let skip = if skip.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("AND g.user_id NOT IN ({})", skip.iter().map(|u| a.p(*u)).collect::<Vec<_>>().join(", "))
|
||||
};
|
||||
let limit = a.p(limit);
|
||||
let sql = format!(
|
||||
"SELECT g.id AS signal_id, g.name, g.keywords, g.description AS signal_description, g.user_id, u.typesafe_key, e.feed_id, e.guid, e.title,
|
||||
e.description, f.title AS feed_title
|
||||
FROM signals g
|
||||
JOIN users u ON u.id = g.user_id
|
||||
JOIN subscriptions s ON s.user_id = g.user_id
|
||||
JOIN entries e ON e.feed_id = s.feed_id
|
||||
LEFT JOIN feeds f ON f.id = e.feed_id
|
||||
WHERE u.typesafe_key IS NOT NULL {skip}
|
||||
AND coalesce(e.published, e.first_seen) >= {since}
|
||||
AND NOT EXISTS (SELECT 1 FROM signal_verdicts v
|
||||
WHERE v.signal_id = g.id AND v.feed_id = e.feed_id AND v.guid = e.guid)
|
||||
AND NOT EXISTS (SELECT 1 FROM hidden h
|
||||
WHERE h.user_id = g.user_id AND h.feed_id = e.feed_id AND h.guid = e.guid)
|
||||
ORDER BY coalesce(e.published, e.first_seen) DESC, e.feed_id, e.guid, g.user_id
|
||||
LIMIT {limit}"
|
||||
);
|
||||
let mut out: Vec<Unjudged> = Vec::new();
|
||||
for r in self.rows(&sql, a.0).await? {
|
||||
let (user_id, feed_id, guid): (i64, String, String) =
|
||||
(r.try_get("", "user_id")?, r.try_get("", "feed_id")?, r.try_get("", "guid")?);
|
||||
let signal = Ask {
|
||||
id: r.try_get("", "signal_id")?,
|
||||
name: r.try_get("", "name")?,
|
||||
keywords: keywords(r.try_get("", "keywords")?).unwrap_or_default(),
|
||||
// Named apart from the item's own description, which is in the same row.
|
||||
description: r.try_get("", "signal_description")?,
|
||||
};
|
||||
// Rows of one item for one person come together, by the ORDER BY.
|
||||
match out.last_mut() {
|
||||
Some(u) if (u.user_id, &u.feed_id, &u.guid) == (user_id, &feed_id, &guid) => u.signals.push(signal),
|
||||
_ => out.push(Unjudged {
|
||||
user_id,
|
||||
key: r.try_get("", "typesafe_key")?,
|
||||
feed_id,
|
||||
guid,
|
||||
feed_title: r.try_get("", "feed_title")?,
|
||||
title: r.try_get("", "title")?,
|
||||
description: r.try_get("", "description")?,
|
||||
signals: vec![signal],
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn record_verdicts(&self, feed_id: &str, guid: &str, verdicts: &[(i64, bool)]) -> Result<()> {
|
||||
for (signal, hit) in verdicts {
|
||||
// Not for a Signal removed while Jev was being asked, which Postgres's foreign key
|
||||
// would refuse and SQLite would keep for nothing.
|
||||
// shortcut: keywords changed mid-call keep a verdict on the old ones for that item.
|
||||
self.exec(
|
||||
"INSERT INTO signal_verdicts (signal_id, feed_id, guid, hit)
|
||||
SELECT $1, $2, $3, $4 WHERE EXISTS (SELECT 1 FROM signals WHERE id = $1)
|
||||
ON CONFLICT (signal_id, feed_id, guid) DO UPDATE SET hit = excluded.hit",
|
||||
vec![(*signal).into(), feed_id.into(), guid.into(), (*hit).into()],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -2581,6 +2897,23 @@ mod tests {
|
||||
assert_eq!(db.http_state("f").await.unwrap().etag.as_deref(), Some("\"b\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_database_from_before_body_hashes_gains_the_columns() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.record_feed("f", "https://x/f", Some("F"), None, None, None, None, None, None).await.unwrap();
|
||||
db.set_body("f", "abc").await.unwrap();
|
||||
assert_eq!(db.http_state("f").await.unwrap().body_hash.as_deref(), Some("abc"));
|
||||
// A database from before #242.
|
||||
for sql in ["ALTER TABLE feeds DROP COLUMN body_hash", "ALTER TABLE feeds DROP COLUMN same_since"] {
|
||||
db.exec_for_test(sql).await.unwrap();
|
||||
}
|
||||
create_missing(&db.orm).await.unwrap();
|
||||
let st = db.http_state("f").await.unwrap();
|
||||
assert_eq!((st.body_hash, st.same_since), (None, None), "every feed is read whole once more");
|
||||
db.set_body("f", "def").await.unwrap();
|
||||
assert_eq!(db.http_state("f").await.unwrap().body_hash.as_deref(), Some("def"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_sort_column_runs_and_orders_both_ways() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
@@ -3041,6 +3374,70 @@ mod tests {
|
||||
assert_eq!(listening().await, ["h", "e"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_signal_shows_what_jev_said_fits_and_only_to_its_owner() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
let t = now();
|
||||
db.exec_for_test(&format!(
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'a',true), (2,'b',false);
|
||||
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'), (1,'g'), (2,'f');
|
||||
INSERT INTO entries (feed_id, guid, title, published, first_seen) VALUES
|
||||
('f','x','Monaco qualifying',{t},{t}),
|
||||
('g','y','Bread',{t},{t}),
|
||||
('g','old','Silverstone 1998',100,100),
|
||||
('h','z','Not subscribed',{t},{t});"
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let words = |w: &[&str]| w.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let f1 = db.add_signal(1, "F1", &words(&["formula 1"]), None).await.unwrap();
|
||||
let food = db.add_signal(1, "Food", &words(&["baking"]), Some("Bread and cakes")).await.unwrap();
|
||||
db.add_signal(2, "Theirs", &words(&["x"]), None).await.unwrap();
|
||||
|
||||
assert!(db.unjudged(&[], 50).await.unwrap().is_empty(), "nobody has a key yet");
|
||||
db.set_typesafe_key(1, Some("k")).await.unwrap();
|
||||
let queue = db.unjudged(&[], 50).await.unwrap();
|
||||
let mut seen: Vec<_> = queue.iter().map(|u| (u.guid.as_str(), u.signals.len())).collect();
|
||||
seen.sort();
|
||||
assert_eq!(seen, [("x", 2), ("y", 2)], "each recent subscribed item once, with both Signals; old and unsubscribed left out");
|
||||
let ask = queue[0].signals.iter().find(|a| a.id == food).unwrap();
|
||||
assert_eq!((ask.name.as_str(), ask.description.as_deref()), ("Food", Some("Bread and cakes")), "Jev is asked about the name and the sentence");
|
||||
assert!(db.unjudged(&[1], 50).await.unwrap().is_empty(), "a refused key is passed over");
|
||||
|
||||
db.record_verdicts("f", "x", &[(f1, true), (food, false)]).await.unwrap();
|
||||
db.record_verdicts("g", "y", &[(f1, false), (food, true)]).await.unwrap();
|
||||
assert!(db.unjudged(&[], 50).await.unwrap().is_empty(), "nothing is asked twice");
|
||||
|
||||
let order = order_sql("published", "desc");
|
||||
let page = db.signal_entries(1, f1, Filter::All, None, 0, 50, &order).await.unwrap();
|
||||
assert_eq!(page.iter().map(|e| e.guid.as_str()).collect::<Vec<_>>(), ["x"]);
|
||||
assert_eq!(db.signal_count(1, food, Filter::All, None).await.unwrap(), 1);
|
||||
assert_eq!(db.signals(1).await.unwrap().iter().map(|s| (s.items, s.unread)).collect::<Vec<_>>(), [(1, 1), (1, 1)]);
|
||||
db.exec_for_test("INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position) VALUES (1,'f','x',true,false,0)")
|
||||
.await
|
||||
.unwrap();
|
||||
let f = db.signal(1, f1).await.unwrap().unwrap();
|
||||
assert_eq!((f.items, f.unread), (1, 0), "read in its feed is read in the Signal, and still one of its items");
|
||||
|
||||
assert!(db.signal(2, f1).await.unwrap().is_none(), "someone else's Signal is not found");
|
||||
assert!(!db.set_signal(2, f1, SignalChange { name: Some("mine"), ..Default::default() }).await.unwrap());
|
||||
assert!(!db.remove_signal(2, f1).await.unwrap());
|
||||
|
||||
db.set_signal(1, f1, SignalChange { words: Some(&words(&["f1"])), ..Default::default() }).await.unwrap();
|
||||
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 0, "new keywords are judged afresh");
|
||||
db.record_verdicts("f", "x", &[(f1, true)]).await.unwrap();
|
||||
db.set_signal(1, f1, SignalChange { description: Some(Some("Grand prix racing")), ..Default::default() }).await.unwrap();
|
||||
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 0, "so is a new sentence");
|
||||
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().description.as_deref(), Some("Grand prix racing"));
|
||||
db.record_verdicts("f", "x", &[(f1, true)]).await.unwrap();
|
||||
db.set_signal(1, f1, SignalChange { icon: Some(Some("solid/flag-checkered")), ..Default::default() }).await.unwrap();
|
||||
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 1, "a new icon asks nothing again");
|
||||
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().icon.as_deref(), Some("solid/flag-checkered"));
|
||||
assert!(db.remove_signal(1, food).await.unwrap());
|
||||
db.record_verdicts("f", "x", &[(food, true)]).await.unwrap();
|
||||
assert_eq!(db.signals(1).await.unwrap().len(), 1, "a verdict for a removed Signal is not kept");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_lists_hide_items_from_whoever_keeps_them() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
|
||||
@@ -55,6 +55,9 @@ pub fn configured(cfg: &config::Config) -> bool {
|
||||
target(cfg).is_some()
|
||||
}
|
||||
|
||||
/// A span the whole call, its answer read and parsed included: the directory's own span ends when it
|
||||
/// has answered, so a slow read of a large answer showed under no span (#250).
|
||||
#[tracing::instrument(name = "directory_call", skip_all, fields(http.request.method = %method, url.path = %path.split('?').next().unwrap_or(path), http.response.body.size))]
|
||||
async fn call<T: DeserializeOwned>(ctx: &Ctx, method: reqwest::Method, path: &str, body: Option<&[SnapshotEntry]>) -> Result<T, DirError> {
|
||||
let (base, token) = target(&ctx.cfg()).ok_or(DirError::NotConfigured)?;
|
||||
let mut req = ctx.client.request(method, format!("{base}{path}")).bearer_auth(token).timeout(std::time::Duration::from_secs(20));
|
||||
@@ -62,8 +65,17 @@ async fn call<T: DeserializeOwned>(ctx: &Ctx, method: reqwest::Method, path: &st
|
||||
if let Some(tp) = ipx_directory::otel::traceparent() {
|
||||
req = req.header("traceparent", tp);
|
||||
}
|
||||
// A snapshot is about 100 KB of JSON, sent gzipped; the directory unpacks it (#253).
|
||||
if let Some(b) = body {
|
||||
req = req.json(b);
|
||||
use std::io::Write as _;
|
||||
let json = serde_json::to_vec(b).map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
gz.write_all(&json).map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
let packed = gz.finish().map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
req = req
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.header(reqwest::header::CONTENT_ENCODING, "gzip")
|
||||
.body(packed);
|
||||
}
|
||||
let resp = req.send().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
let status = resp.status();
|
||||
@@ -73,6 +85,7 @@ async fn call<T: DeserializeOwned>(ctx: &Ctx, method: reqwest::Method, path: &st
|
||||
}
|
||||
// A removal or an unblock answers 204, with nothing to read: that reads as null, which is ().
|
||||
let body = resp.bytes().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
tracing::Span::current().record("http.response.body.size", body.len());
|
||||
serde_json::from_slice(if body.is_empty() { b"null" } else { &body }).map_err(|e| DirError::Unreachable(format!("its answer did not read: {e:#}")))
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,12 @@ pub mod feeds {
|
||||
/// are re-derived instead. Customising one promotes it to config.
|
||||
#[sea_orm(default_value = false)]
|
||||
pub managed: bool,
|
||||
/// SHA-256 of the body last read, in hex: the same body again is read as "not modified"
|
||||
/// (#242). Added after the table was, so `create_missing` adds it, and `same_since`.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub body_hash: Option<String>,
|
||||
/// When the body last changed: the longer ago, the less often the feed is checked (#242).
|
||||
pub same_since: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
@@ -151,6 +157,12 @@ pub mod users {
|
||||
pub theme: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub theme_mode: Option<String>,
|
||||
/// Their own TypeSafe key, which Jev judges their Signals' items on (#254). Never sent
|
||||
/// back to the page.
|
||||
// shortcut: kept as it is, since every call needs it whole; encrypt it with a key of the
|
||||
// server's if the database is ever shared with anything else.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub typesafe_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
@@ -276,6 +288,67 @@ pub mod hidden {
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// A feed someone makes from their own subscriptions (#254): the items Jev says fit its
|
||||
/// keywords, whichever feed they came from.
|
||||
pub mod signals {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "signals")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i64,
|
||||
pub user_id: i64,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub name: String,
|
||||
/// JSON array of strings.
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub keywords: String,
|
||||
/// What its owner means by it, in a sentence of their own, when the name and the words
|
||||
/// are not enough (#259).
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub description: Option<String>,
|
||||
/// Its icon, a Font Awesome Free one as `style/name` (`solid/robot`), in place of its
|
||||
/// initials (#261).
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub icon: Option<String>,
|
||||
pub created: i64,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// What Jev said of each item a Signal has been asked about, fitting or not: an item with no row
|
||||
/// here is still to be asked.
|
||||
pub mod signal_verdicts {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "signal_verdicts")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub signal_id: i64,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub guid: String,
|
||||
pub hit: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::signals::Entity",
|
||||
from = "Column::SignalId",
|
||||
to = "super::signals::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Signal,
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
pub mod sessions {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
|
||||
@@ -34,9 +34,19 @@ pub enum Event {
|
||||
TorrentDeferred { feed: String, url: String },
|
||||
/// DeadAir finished a file's copy (#171), so a page showing it as in progress can stop.
|
||||
DeadairDone { feed: String, enclosure: i64, saved_s: i64 },
|
||||
/// Jev found items for someone's Signals (#258), so their page fetches the counts again.
|
||||
SignalHits { user: i64, hits: usize },
|
||||
Reaped { path: String, bytes: u64 },
|
||||
/// Terminal: a client that asked for work stops reading here.
|
||||
ScanDone { feeds: usize },
|
||||
ScanDone {
|
||||
feeds: usize,
|
||||
/// The feed or folder the scan was limited to, and whether someone asked for it: a page
|
||||
/// stops the button they pressed at the end of their scan, not at a scheduled one's (#233).
|
||||
#[serde(default)]
|
||||
only: Option<String>,
|
||||
#[serde(default)]
|
||||
forced: bool,
|
||||
},
|
||||
ReapDone { files: usize, bytes: u64 },
|
||||
Status { feeds: usize, pending: i64, downloaded: i64 },
|
||||
Error { msg: String },
|
||||
@@ -82,7 +92,8 @@ impl Event {
|
||||
Event::FeedStart { feed } => format!("{feed}: checking"),
|
||||
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
|
||||
Event::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"),
|
||||
Event::ScanDone { feeds } => format!("scan complete, {feeds} feed(s)"),
|
||||
Event::SignalHits { hits, .. } => format!("{hits} item(s) picked out for Signals"),
|
||||
Event::ScanDone { feeds, .. } => format!("scan complete, {feeds} feed(s)"),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -136,6 +147,7 @@ impl Emitter {
|
||||
}
|
||||
|
||||
pub fn emit(&self, e: Event) {
|
||||
let e = e.redacted();
|
||||
// Also log it. Scans and downloads travel as events, not tracing calls, so
|
||||
// without this the log view shows only startup and HTTP lines and none of the
|
||||
// work the daemon is actually doing. Progress goes to debug: it fires on every
|
||||
@@ -159,6 +171,29 @@ impl Emitter {
|
||||
}
|
||||
}
|
||||
|
||||
impl Event {
|
||||
/// The event as it may go out: to the log, to every open page whoever's feed it is, to a
|
||||
/// terminal. Addresses lose their query and password, where a private feed keeps its key, and
|
||||
/// so do the ones in an error's words (#238).
|
||||
fn redacted(mut self) -> Self {
|
||||
use crate::feed::{redact, redact_text};
|
||||
match &mut self {
|
||||
Event::DownloadError { url, msg, .. } => {
|
||||
*url = redact(url);
|
||||
*msg = redact_text(msg);
|
||||
}
|
||||
Event::TorrentDeferred { url, .. } => *url = redact(url),
|
||||
Event::FeedMoved { from, to, .. } => {
|
||||
*from = redact(from);
|
||||
*to = redact(to);
|
||||
}
|
||||
Event::FeedError { msg, .. } | Event::Error { msg } => *msg = redact_text(msg),
|
||||
_ => {}
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// An event, logged once (#91): its words as the message, and `ev`, `feed`, `new` and the rest as
|
||||
/// fields, so Loki reads them without parsing the message. A failure also gets `error.type` and,
|
||||
/// from an HTTP error, `http.response.status_code`, so failures group by kind without a regex.
|
||||
@@ -198,7 +233,7 @@ fn log_event(e: &Event, wire: bool) {
|
||||
)
|
||||
};
|
||||
}
|
||||
// The healthcheck's answer, every 30s: a reply on the socket rather than work done, so it
|
||||
// `ipx status`'s answer: a reply on the socket rather than work done, so it
|
||||
// stays with the rest of the conversation, and the Scans tab stays about scans.
|
||||
if matches!(e, Event::Status { .. }) {
|
||||
return line!(info, "ipx::io", format!("<- {json}"));
|
||||
@@ -378,6 +413,30 @@ mod tests {
|
||||
assert!(matches!(rx.try_recv(), Ok(Event::FeedSkip { feed, .. }) if feed == "g"), "nothing missed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_goes_out_names_no_private_feeds_key() {
|
||||
let (tx, mut rx) = broadcast::channel(8);
|
||||
let out = Emitter::socket(tx, false);
|
||||
let key = "auth=SECRET";
|
||||
out.emit(Event::FeedError {
|
||||
feed: "f".into(),
|
||||
msg: format!("connecting: error sending request for url (https://x/rss?{key}&show=1): operation timed out"),
|
||||
});
|
||||
out.emit(Event::DownloadError {
|
||||
feed: "f".into(),
|
||||
enclosure: 1,
|
||||
url: format!("https://cdn/ep.mp3?{key}"),
|
||||
msg: format!("connecting: error sending request for url (https://cdn/ep.mp3?{key})"),
|
||||
});
|
||||
out.emit(Event::FeedMoved { feed: "f".into(), from: format!("https://x/rss?{key}"), to: format!("https://y/rss?{key}") });
|
||||
// What every open page is sent, whoever's feed it is (#238).
|
||||
for _ in 0..3 {
|
||||
let sent = serde_json::to_string(&rx.try_recv().unwrap()).unwrap();
|
||||
assert!(!sent.contains("SECRET"), "{sent}");
|
||||
assert!(sent.contains("https://"), "the address is still there, without its key: {sent}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feed_that_moved_says_so_on_the_wire_and_in_words() {
|
||||
let e = Event::FeedMoved { feed: "x".into(), from: "http://a/f".into(), to: "https://a/f".into() };
|
||||
@@ -434,7 +493,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn only_completion_events_end_a_client_session() {
|
||||
assert!(Event::ScanDone { feeds: 1 }.is_terminal());
|
||||
assert!(Event::ScanDone { feeds: 1, only: None, forced: false }.is_terminal());
|
||||
assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal());
|
||||
assert!(!Event::FeedDone {
|
||||
feed: "a".into(),
|
||||
|
||||
@@ -13,6 +13,7 @@ mod ipc;
|
||||
mod logbuf;
|
||||
mod metrics;
|
||||
mod retention;
|
||||
mod signals;
|
||||
mod torrent;
|
||||
mod web;
|
||||
|
||||
@@ -159,6 +160,10 @@ pub struct Ctx {
|
||||
/// Wakes DeadAir's maker when a show is turned on, so its copies start at once instead of
|
||||
/// at the next pass (#171).
|
||||
pub deadair: tokio::sync::Notify,
|
||||
/// Wakes the Signals judge when there are new items or a Signal is made or changed (#254).
|
||||
pub signals: tokio::sync::Notify,
|
||||
/// People whose TypeSafe key was refused, passed over until they save another.
|
||||
pub signals_refused: std::sync::Mutex<std::collections::HashSet<i64>>,
|
||||
/// When the Directory was last cleared of dead feeds (`clean_directory`), 0 for not since
|
||||
/// starting.
|
||||
pub directory_cleaned: std::sync::atomic::AtomicI64,
|
||||
@@ -254,8 +259,8 @@ impl Ctx {
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
// The daemon only: `ipx status` runs every half minute as the healthcheck, and a trace
|
||||
// for each would bury the ones worth looking at.
|
||||
// The daemon only: a trace for every `ipx status` a script runs would bury the ones worth
|
||||
// looking at.
|
||||
let otel = match cli.command {
|
||||
Command::Daemon { .. } => ipx_directory::otel::provider("ipx")?,
|
||||
_ => None,
|
||||
@@ -299,14 +304,14 @@ async fn main() -> Result<()> {
|
||||
use opentelemetry::trace::TracerProvider;
|
||||
tracing_opentelemetry::layer()
|
||||
.with_tracer(p.tracer("ipx"))
|
||||
.with_filter(tracing_subscriber::EnvFilter::new("ipx=info"))
|
||||
.with_filter(tracing_subscriber::EnvFilter::new("ipx=info,ipx_feed=info"))
|
||||
}))
|
||||
.init();
|
||||
}
|
||||
|
||||
let config_path = cli.config.clone().unwrap_or_else(config::config_path);
|
||||
let cfg = config::Config::load(&config_path)?;
|
||||
let db = db::Db::open(&db::location()).await?;
|
||||
let db = db::Db::open(&db::location(), matches!(cli.command, Command::Daemon { .. })).await?;
|
||||
|
||||
// A daemon owns the state; don't have two processes downloading the same thing.
|
||||
let wire_cmd = match &cli.command {
|
||||
@@ -355,6 +360,7 @@ async fn main() -> Result<()> {
|
||||
feed_client: reqwest::Client::builder()
|
||||
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.read_timeout(feed::FEED_SILENCE)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?,
|
||||
out: if is_daemon { Emitter::socket(events.clone(), false) } else { Emitter::terminal() },
|
||||
@@ -366,6 +372,8 @@ async fn main() -> Result<()> {
|
||||
cfg_write: Default::default(),
|
||||
directory_cleaned: Default::default(),
|
||||
deadair: Default::default(),
|
||||
signals: Default::default(),
|
||||
signals_refused: Default::default(),
|
||||
directory_wake: Default::default(),
|
||||
directory_shared: Default::default(),
|
||||
directory_resend: Default::default(),
|
||||
@@ -568,7 +576,7 @@ pub(crate) async fn status(ctx: &Ctx) -> Event {
|
||||
match ctx.db.counts().await {
|
||||
Ok((pending, downloaded)) => {
|
||||
let feeds = subscriptions(ctx).await.map(|s| s.len()).unwrap_or(0);
|
||||
// Kept for /metrics, which asks the database nothing (#195): the healthcheck asks this every 30 s.
|
||||
// Kept for /metrics, which asks the database nothing (#195); the daemon counts every 30 s.
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let st = &metrics::STATS;
|
||||
st.feeds.store(feeds as i64, Relaxed);
|
||||
@@ -662,7 +670,23 @@ async fn daemon(
|
||||
// Off the worker: the first pass over a library is hours of ffmpeg, and scans and downloads
|
||||
// must not wait behind it.
|
||||
tokio::spawn(deadair::run(ctx.clone(), rx_stop.clone()));
|
||||
tokio::spawn(signals::run(ctx.clone(), rx_stop.clone()));
|
||||
tokio::spawn(directory::run(ctx.clone(), rx_stop.clone()));
|
||||
// The library's counts for /metrics. The Docker healthcheck asked for them every 30 s until
|
||||
// it was removed: Docker runs it with exec, which failed on Tower whenever root was logged
|
||||
// out (#218).
|
||||
{
|
||||
let (ctx, mut stop) = (ctx.clone(), rx_stop.clone());
|
||||
tokio::spawn(async move {
|
||||
let mut every = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = every.tick() => { status(&ctx).await; }
|
||||
_ = stop.changed() => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Runs one job, abandoning it if a signal arrives. Returns false to end the loop.
|
||||
async fn until_stopped(
|
||||
@@ -1245,18 +1269,33 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
|
||||
const AHEAD: usize = 6;
|
||||
let mut bodies = futures_util::StreamExt::buffered(
|
||||
futures_util::stream::iter(due.iter().map(|(sub, state)| {
|
||||
let (client, feed_cfg) = (ctx.feed_client.clone(), sub.cfg.clone());
|
||||
let (client, web, feed_cfg) = (ctx.feed_client.clone(), ctx.client.clone(), sub.cfg.clone());
|
||||
let (etag, modified) = (state.etag.clone(), state.last_modified.clone());
|
||||
let early = !feed::is_patreon_creator(&feed_cfg.url);
|
||||
// Named for its feed: the fetch's own span has only the address, without its query
|
||||
// since #225, which for every YouTube channel is the same (#229).
|
||||
let span = tracing::info_span!("fetch_ahead", feed = %sub.id);
|
||||
tokio::spawn(tracing::Instrument::instrument(
|
||||
async move {
|
||||
if !early {
|
||||
return None;
|
||||
}
|
||||
let _fetching = metrics::busy(&metrics::STATS.feeds_fetching);
|
||||
Some(feed::fetch(&client, &feed_cfg.url, feed_cfg.auth(), etag.as_deref(), modified.as_deref()).await)
|
||||
let got = feed::fetch(&client, &feed_cfg.url, feed_cfg.auth(), etag.as_deref(), modified.as_deref()).await;
|
||||
// A forced scan looks for the site's icon of each feed naming no artwork: here,
|
||||
// a few feeds at a time, not each in its turn, 22 s of a 62 s scan (#228).
|
||||
let icon = match &got {
|
||||
Ok((feed::Fetched::Body { bytes, .. }, _)) if force => match feed::parse(bytes) {
|
||||
Ok(feed::ParsedFeed { image: None, site: Some(site), .. }) => {
|
||||
Some(feed::site_icon(&web, &site).await.unwrap_or_default())
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
Some((got, icon))
|
||||
},
|
||||
tracing::Span::current(),
|
||||
span,
|
||||
))
|
||||
})),
|
||||
AHEAD,
|
||||
@@ -1330,7 +1369,7 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.out.emit(Event::ScanDone { feeds: scanned });
|
||||
ctx.out.emit(Event::ScanDone { feeds: scanned, only: only.map(str::to_owned), forced: force });
|
||||
// What people subscribe to may have changed, and new feeds have titles now: share it.
|
||||
ctx.directory_wake.notify_one();
|
||||
Ok(())
|
||||
@@ -1500,7 +1539,7 @@ async fn retire_stranded(ctx: &Ctx) -> Result<usize> {
|
||||
pub fn due_at(cfg: &config::Config, feed: &config::Feed, state: &db::HttpState, subscribed: bool) -> Option<i64> {
|
||||
let last = state.last_checked?;
|
||||
let floor = if subscribed { 0 } else { 86_400 };
|
||||
Some(last + due_after(cfg, feed, state.ttl_mins, state.error_since, last).max(floor) as i64)
|
||||
Some(last + due_after(cfg, feed, state.ttl_mins, state.error_since, state.same_since, last).max(floor) as i64)
|
||||
}
|
||||
|
||||
/// How long the daemon may sleep before a feed is due: until the earliest one, at least 30 s and
|
||||
@@ -1546,14 +1585,17 @@ pub fn due_after(
|
||||
feed: &config::Feed,
|
||||
ttl_mins: Option<u64>,
|
||||
error_since: Option<i64>,
|
||||
same_since: Option<i64>,
|
||||
last_checked: i64,
|
||||
) -> u64 {
|
||||
let mins = match feed.schedule.as_deref().and_then(config::parse_interval) {
|
||||
Some(explicit) => explicit,
|
||||
None => ttl_mins.unwrap_or(0).max(cfg.general.interval()),
|
||||
let explicit = feed.schedule.as_deref().and_then(config::parse_interval);
|
||||
let usual = match explicit {
|
||||
Some(explicit) => explicit * 60,
|
||||
// A schedule someone set is theirs; otherwise a feed long the same is checked less.
|
||||
None => quiet(ttl_mins.unwrap_or(0).max(cfg.general.interval()) * 60, same_since.map(|s| (last_checked - s).max(0) as u64)),
|
||||
};
|
||||
let failing_for = error_since.map(|since| (last_checked - since).max(0) as u64);
|
||||
backoff(mins * 60, failing_for)
|
||||
backoff(usual, failing_for)
|
||||
}
|
||||
|
||||
/// A failing feed waits as long as it has been failing, so the wait doubles with each failure
|
||||
@@ -1561,6 +1603,17 @@ pub fn due_after(
|
||||
/// never more than a day. Retried hourly, a feed dead for good cost a request, a warning and scan
|
||||
/// time every hour (#99). The first success clears error_since, and with it the backoff; a
|
||||
/// refresh someone asks for is not held back by it.
|
||||
/// A feed whose body has been the same for a while is checked less often, a twelfth of how long
|
||||
/// it has been the same, up to four hours, and on its schedule again once it changes: Patreon and
|
||||
/// Substack send the whole feed at every check, ignoring "not modified", 2.6 GB a day (#242).
|
||||
fn quiet(usual: u64, same_for: Option<u64>) -> u64 {
|
||||
const CEILING: u64 = 4 * 3600;
|
||||
match same_for {
|
||||
Some(f) => usual.max((f / 12).min(CEILING)),
|
||||
None => usual,
|
||||
}
|
||||
}
|
||||
|
||||
fn backoff(usual: u64, failing_for: Option<u64>) -> u64 {
|
||||
const CEILING: u64 = 86_400;
|
||||
match failing_for {
|
||||
@@ -1613,9 +1666,14 @@ async fn scan_one(
|
||||
feed_cfg: &config::Feed,
|
||||
state: &db::HttpState,
|
||||
force: bool,
|
||||
// The body the scan already fetched for it, if it did (#104), and where it moved, if it did.
|
||||
prefetched: Option<Result<(feed::Fetched, Option<String>)>>,
|
||||
// The body the scan already fetched for it, if it did (#104), and where it moved, if it did;
|
||||
// with the site's icon when a forced scan looked it up meanwhile, "" for none (#228).
|
||||
prefetched: Option<(Result<(feed::Fetched, Option<String>)>, Option<String>)>,
|
||||
) -> Result<Outcome> {
|
||||
let (prefetched, icon) = match prefetched {
|
||||
Some((got, icon)) => (Some(got), icon),
|
||||
None => (None, None),
|
||||
};
|
||||
// The queue follows the feed's settings each time it is due, changed or not, so files a
|
||||
// limit or auto-download no longer reaches stop counting as waiting (#113).
|
||||
let policy = policy_for(ctx, id, feed_cfg).await?;
|
||||
@@ -1685,6 +1743,18 @@ async fn scan_one(
|
||||
return Ok(Outcome::Empty);
|
||||
}
|
||||
|
||||
// The same body as last time, from a server that ignores "not modified": nothing to read,
|
||||
// as a 304 would have said (#242). Not when something is missing that a read would fill, as
|
||||
// above, nor when someone asked for a full check.
|
||||
let hash = {
|
||||
use sha2::Digest as _;
|
||||
sha2::Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect::<String>()
|
||||
};
|
||||
if !force && state.body_hash.as_deref() == Some(hash.as_str()) && stored.entries > 0 && stored.image.is_some() {
|
||||
ctx.db.touch_feed(id, &feed_cfg.url).await?;
|
||||
return Ok(Outcome::NotModified);
|
||||
}
|
||||
|
||||
// A subscribed OPML is a list of feeds, not a feed. The original matched on a ".opml"
|
||||
// URL; sniffing the body also catches one served from a URL without that extension.
|
||||
if feed::is_opml(&bytes) {
|
||||
@@ -1717,13 +1787,16 @@ async fn scan_one(
|
||||
&& stored.image.as_deref() != Some(art.as_str())
|
||||
&& !feed::is_image(&ctx.client, art).await
|
||||
{
|
||||
tracing::info!(feed = id, art = %art, "the feed's artwork is not an image; trying its site's icon");
|
||||
tracing::info!(feed = id, art = %feed::redact(art), "the feed's artwork is not an image; trying its site's icon");
|
||||
parsed.image = None;
|
||||
}
|
||||
if parsed.image.is_none() {
|
||||
parsed.image = Some(match (&stored.image, &parsed.site) {
|
||||
(Some(known), _) if !force => known.clone(),
|
||||
(_, Some(site)) => feed::site_icon(&ctx.client, site).await.unwrap_or_default(),
|
||||
(_, Some(site)) => match &icon {
|
||||
Some(found) => found.clone(),
|
||||
None => feed::site_icon(&ctx.client, site).await.unwrap_or_default(),
|
||||
},
|
||||
(_, None) => String::new(),
|
||||
});
|
||||
}
|
||||
@@ -1753,6 +1826,9 @@ async fn scan_one(
|
||||
parsed.category.as_deref(),
|
||||
parsed.description.as_deref(),
|
||||
).await?;
|
||||
if state.body_hash.as_deref() != Some(hash.as_str()) {
|
||||
ctx.db.set_body(id, &hash).await?;
|
||||
}
|
||||
// Asked on a feed's first read, nothing of it stored yet, or when someone forces a refresh:
|
||||
// each call costs, and a feed already here without a category can wait for a forced one.
|
||||
// The answer becomes the catalogue's category, so a feed is not asked twice.
|
||||
@@ -1825,6 +1901,7 @@ async fn scan_one(
|
||||
}, store).await?;
|
||||
if scan.new_entries > 0 {
|
||||
ctx.db.rehide(id).await?; // what is new may hold someone's blocked words
|
||||
ctx.signals.notify_one();
|
||||
}
|
||||
// A new item takes a place among the newest and the oldest of them leaves the queue; a file a
|
||||
// filter lets through again may be outside them.
|
||||
@@ -2124,7 +2201,7 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
|
||||
|
||||
/// Downloads one file and records it. None when it turned out to be a torrent, handed off to run
|
||||
/// on its own, which records it when it finishes.
|
||||
#[tracing::instrument(name = "download", skip_all, fields(feed = feed_id, enclosure = enclosure, url = url))]
|
||||
#[tracing::instrument(name = "download", skip_all, fields(feed = feed_id, enclosure = enclosure, url = %feed::redact(url)))]
|
||||
async fn fetch_one(
|
||||
ctx: &Arc<Ctx>,
|
||||
feed_id: &str,
|
||||
@@ -2282,7 +2359,7 @@ async fn download_one(ctx: &Arc<Ctx>, id: i64) -> Result<()> {
|
||||
}
|
||||
}
|
||||
// Terminal, so a UI waiting on this request stops here.
|
||||
ctx.out.emit(Event::ScanDone { feeds: 1 });
|
||||
ctx.out.emit(Event::ScanDone { feeds: 1, only: None, forced: false });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2350,6 +2427,40 @@ mod tests {
|
||||
assert_eq!(at(Some(1000), false), Some(1000 + 86_400)); // listed, nobody subscribed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feed_long_the_same_is_checked_less_and_a_schedule_set_wins() {
|
||||
let (hour, day) = (3600u64, 86_400u64);
|
||||
assert_eq!(quiet(hour, None), hour);
|
||||
assert_eq!(quiet(hour, Some(6 * hour)), hour, "a few hours the same changes nothing");
|
||||
assert_eq!(quiet(hour, Some(day)), 2 * hour, "a day the same: every two hours");
|
||||
assert_eq!(quiet(hour, Some(30 * day)), 4 * hour, "never past four hours");
|
||||
assert_eq!(quiet(12 * hour, Some(30 * day)), 12 * hour, "never sooner than its schedule");
|
||||
let cfg = config::Config::default();
|
||||
let same = |f: &config::Feed| due_after(&cfg, f, None, None, Some(0), 3 * day as i64);
|
||||
assert_eq!(same(&feed()), 4 * hour);
|
||||
let set = config::Feed { schedule: Some("every 1h".into()), ..feed() };
|
||||
assert_eq!(same(&set), hour, "someone's own schedule wins");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_same_body_again_is_not_modified_unless_a_check_is_forced() {
|
||||
let base = empty_or_feed_server().await;
|
||||
let ctx = Arc::new(test_ctx(config::Config::default()).await);
|
||||
let cfg = config::Feed { url: format!("{base}/feed"), ..feed() };
|
||||
assert!(matches!(scan_one(&ctx, "same", &cfg, &db::HttpState::default(), false, None).await, Ok(Outcome::Feed(_))));
|
||||
let state = ctx.db.http_state("same").await.unwrap();
|
||||
let since = state.same_since.expect("when it was first read");
|
||||
assert!(state.body_hash.is_some());
|
||||
// Patreon's way: the whole feed again, no validator honoured (#242).
|
||||
assert!(matches!(scan_one(&ctx, "same", &cfg, &state, false, None).await, Ok(Outcome::NotModified)));
|
||||
assert_eq!(ctx.db.http_state("same").await.unwrap().same_since, Some(since), "still the same since then");
|
||||
assert!(matches!(scan_one(&ctx, "same", &cfg, &state, true, None).await, Ok(Outcome::Feed(_))), "a forced check reads it");
|
||||
// Changed: read, and the same from now.
|
||||
let moved = config::Feed { url: format!("{base}/fixed"), ..feed() };
|
||||
assert!(matches!(scan_one(&ctx, "same", &moved, &state, false, None).await, Ok(Outcome::Feed(_))));
|
||||
assert_ne!(ctx.db.http_state("same").await.unwrap().body_hash, state.body_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_listed_feed_nobody_subscribes_to_downloads_nothing() {
|
||||
let mut f = feed();
|
||||
@@ -2503,6 +2614,8 @@ mod tests {
|
||||
retry: Default::default(),
|
||||
cfg_write: Default::default(),
|
||||
deadair: Default::default(),
|
||||
signals: Default::default(),
|
||||
signals_refused: Default::default(),
|
||||
directory_wake: Default::default(),
|
||||
directory_shared: Default::default(),
|
||||
directory_resend: Default::default(),
|
||||
@@ -2644,6 +2757,58 @@ mod tests {
|
||||
assert!(deadair::plan_for(&ctx, u, "f", 7).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_people_play_one_file_each_in_their_own_version_and_place() {
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
let db = &ctx.db;
|
||||
for sql in [
|
||||
"INSERT INTO entries (feed_id, guid, first_seen, duration) VALUES ('f', 'g', 1, 600)",
|
||||
"INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at)
|
||||
VALUES (7, 'f', 'g', 'http://x/a.mp3', '/x/a.mp3', 'done', 100)",
|
||||
] {
|
||||
db.sql(sql).await.unwrap();
|
||||
}
|
||||
db.store_plans(7, r#"{"300": {"cuts": [[60000,120000],[200000,260000]], "pieces": []},
|
||||
"500": {"cuts": [[60000,100000]], "pieces": []}}"#).await.unwrap();
|
||||
// One file on disk, three listeners at once: DeadAir at the usual pause, at a longer
|
||||
// one, and the original.
|
||||
let (usual, long, plain) = (
|
||||
db.create_user("usual", None, false).await.unwrap(),
|
||||
db.create_user("long", None, false).await.unwrap(),
|
||||
db.create_user("plain", None, false).await.unwrap(),
|
||||
);
|
||||
for u in [usual, long, plain] {
|
||||
db.subscribe(u, "f").await.unwrap();
|
||||
}
|
||||
for u in [usual, long] {
|
||||
db.set_deadair(u, "f", Some(true)).await.unwrap();
|
||||
}
|
||||
db.set_deadair_pause(long, "f", 500).await.unwrap();
|
||||
// Each is handed their own playlist, as /media/7/deadair.m3u8 does.
|
||||
let playing = deadair::Playing::default();
|
||||
for u in [usual, long] {
|
||||
let plan = deadair::plan_for(&ctx, u, "f", 7).await.unwrap().unwrap();
|
||||
playing.lock().unwrap().insert((u, "f".into(), "g".into()), Arc::new(plan));
|
||||
}
|
||||
assert!(deadair::plan_for(&ctx, plain, "f", 7).await.unwrap().is_none());
|
||||
// All three save 3:20 of what they hear, as the position route does: each is read in
|
||||
// that person's own clock and kept as a place in the original, theirs alone.
|
||||
let mut kept = vec![];
|
||||
for u in [usual, long, plain] {
|
||||
let (secs, len) = deadair::on_save(&ctx, &playing, u, "f", "g", 200, None).await.unwrap();
|
||||
db.set_position(u, "f", "g", secs, len).await.unwrap();
|
||||
kept.push(secs);
|
||||
}
|
||||
assert_eq!(kept, [320, 240, 200], "two minutes cut before 3:20, forty seconds, none");
|
||||
// Each comes back to 3:20 of their own version, whatever the others saved.
|
||||
let order = db::order_sql("published", "desc");
|
||||
for (u, len) in [(usual, 480), (long, 560), (plain, 600)] {
|
||||
let mut rows = db.entries_in(u, Some("f"), db::Filter::All, None, 0, 10, &order).await.unwrap();
|
||||
deadair::for_reader(&ctx, u, &mut rows).await.unwrap();
|
||||
assert_eq!((rows[0].position, rows[0].duration), (200, Some(len)), "user {u}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_changes_to_the_catalogue_at_once_both_keep() {
|
||||
let ctx = Arc::new(test_ctx(config::Config::default()).await);
|
||||
@@ -2743,6 +2908,18 @@ mod tests {
|
||||
assert_eq!(text().await, ["As it should read."]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_forced_scan_takes_the_icon_looked_up_ahead() {
|
||||
let ctx = Arc::new(test_ctx(config::Config::default()).await);
|
||||
// No artwork, and a site nothing answers at: only the icon found ahead can be stored (#228).
|
||||
let body = b"<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title><link>http://127.0.0.1:9/</link><item><title>One</title><guid>g1</guid></item></channel></rss>".to_vec();
|
||||
let got = feed::Fetched::Body { bytes: body, etag: None, last_modified: None };
|
||||
let icon = "https://example.com/favicon.ico".to_string();
|
||||
let cfg = config::Feed { url: "http://127.0.0.1:9/feed".into(), ..feed() };
|
||||
scan_one(&ctx, "iconic", &cfg, &db::HttpState::default(), true, Some((Ok((got, None)), Some(icon.clone())))).await.unwrap();
|
||||
assert_eq!(ctx.db.feed_summary("iconic").await.unwrap().image, Some(icon));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nothing_from_a_feed_that_never_posted_is_an_error_and_from_one_that_has_is_not() {
|
||||
let base = empty_or_feed_server().await;
|
||||
|
||||
@@ -36,7 +36,7 @@ pub struct Stats {
|
||||
/// Artwork served from disk, and fetched from the publisher because it was not there.
|
||||
pub art_hits: AtomicU64,
|
||||
pub art_misses: AtomicU64,
|
||||
/// The library as the healthcheck last counted it, every 30 s; -1 until it has.
|
||||
/// The library as the daemon last counted it, every 30 s; -1 until it has.
|
||||
pub feeds: AtomicI64,
|
||||
pub pending: AtomicI64,
|
||||
pub downloaded: AtomicI64,
|
||||
@@ -135,7 +135,9 @@ pub fn spawn(bind: &str, s: Sources) -> anyhow::Result<std::net::SocketAddr> {
|
||||
let s = s.clone();
|
||||
async move { ([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(&s)) }
|
||||
}),
|
||||
);
|
||||
)
|
||||
// Prometheus asks for gzip, every 15 s (#253).
|
||||
.layer(tower_http::compression::CompressionLayer::new().gzip(true));
|
||||
match tokio::net::TcpListener::from_std(listener) {
|
||||
Ok(l) => {
|
||||
if let Err(e) = axum::serve(l, app).await {
|
||||
@@ -169,13 +171,15 @@ pub fn render(s: &Sources) -> String {
|
||||
gauge(&mut out, "ipx_runtime_workers", "The async runtime's worker threads.", &[("", rt.num_workers() as f64)]);
|
||||
gauge(&mut out, "ipx_runtime_tasks", "Tasks alive in the async runtime.", &[("", rt.num_alive_tasks() as f64)]);
|
||||
gauge(&mut out, "ipx_runtime_queue", "Tasks waiting in the runtime's global queue.", &[("", rt.global_queue_depth() as f64)]);
|
||||
let (rss, fds) = process();
|
||||
let (rss, fds) = ipx_directory::metrics::process();
|
||||
if let Some(v) = rss {
|
||||
gauge(&mut out, "ipx_process_resident_bytes", "Memory the process holds.", &[("", v)]);
|
||||
}
|
||||
if let Some(v) = fds {
|
||||
gauge(&mut out, "ipx_process_open_fds", "Files and sockets the process has open.", &[("", v)]);
|
||||
}
|
||||
counter(&mut out, "ipx_feed_fetches_total", "Feeds fetched, answered 304 included.", &[("", ipx_feed::FETCHES.load(Relaxed) as f64)]);
|
||||
counter(&mut out, "ipx_feed_bytes_total", "Bytes of feed bodies fetched.", &[("", ipx_feed::FETCHED_BYTES.load(Relaxed) as f64)]);
|
||||
gauge(&mut out, "ipx_uptime_seconds", "Seconds since the daemon started.", &[("", s.started.elapsed().as_secs() as f64)]);
|
||||
let version = format!("version=\"{}\"", env!("CARGO_PKG_VERSION"));
|
||||
gauge(&mut out, "ipx_build_info", "The version running, as a label.", &[(&version, 1.0)]);
|
||||
@@ -218,9 +222,9 @@ fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_li
|
||||
counter(out, "ipx_art_requests_total", "Artwork asked for: served from disk, or fetched from the publisher.",
|
||||
&[("result=\"hit\"", st.art_hits.load(Relaxed) as f64), ("result=\"miss\"", st.art_misses.load(Relaxed) as f64)]);
|
||||
for (name, help, v) in [
|
||||
("ipx_feeds", "Feeds in the catalogue, as the healthcheck last counted.", &st.feeds),
|
||||
("ipx_downloads_pending", "Files a scan will download on its own, as the healthcheck last counted.", &st.pending),
|
||||
("ipx_files_downloaded", "Files on disk, as the healthcheck last counted.", &st.downloaded),
|
||||
("ipx_feeds", "Feeds in the catalogue, as last counted.", &st.feeds),
|
||||
("ipx_downloads_pending", "Files a scan will download on its own, as last counted.", &st.pending),
|
||||
("ipx_files_downloaded", "Files on disk, as last counted.", &st.downloaded),
|
||||
] {
|
||||
let v = v.load(Relaxed);
|
||||
if v >= 0 {
|
||||
@@ -248,16 +252,6 @@ fn write(out: &mut String, kind: &str, name: &str, help: &str, samples: &[(&str,
|
||||
}
|
||||
}
|
||||
|
||||
/// The process's resident memory in bytes and its open files, from /proc; None elsewhere.
|
||||
pub fn process() -> (Option<f64>, Option<f64>) {
|
||||
let rss = std::fs::read_to_string("/proc/self/status").ok().and_then(|s| {
|
||||
let kb = s.lines().find_map(|l| l.strip_prefix("VmRSS:"))?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()?;
|
||||
Some(kb * 1024.0)
|
||||
});
|
||||
let fds = std::fs::read_dir("/proc/self/fd").ok().map(|d| d.count() as f64);
|
||||
(rss, fds)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -293,13 +287,13 @@ mod tests {
|
||||
assert!(out.contains("ipx_worker_busy 0\n") && !out.contains("ipx_worker_job_seconds"), "{out}");
|
||||
assert!(out.contains("ipx_jobs_total{kind=\"scan\"} 1\n") && out.contains("# TYPE ipx_jobs_total counter"), "{out}");
|
||||
assert!(out.contains("ipx_event_queue 3\n") && out.contains("ipx_feed_retries 1\n"));
|
||||
// The library's counts are left out until the healthcheck has counted it.
|
||||
// The library's counts are left out until the daemon has counted it.
|
||||
assert!(!out.contains("ipx_feeds "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_process_reads_its_memory_and_files_on_linux() {
|
||||
let (rss, fds) = process();
|
||||
let (rss, fds) = ipx_directory::metrics::process();
|
||||
assert!(rss.unwrap() > 0.0 && fds.unwrap() > 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
365
crates/ipx/src/signals.rs
Normal file
365
crates/ipx/src/signals.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
//! Signals (#254): feeds someone makes from their own subscriptions, holding the items TypeSafe's
|
||||
//! Jev says fit the keywords they gave. Jev is asked on their own key, once for each item, with a
|
||||
//! question for each of their Signals that has not had it yet, and what it says is kept in
|
||||
//! `signal_verdicts`, fitting or not, so nothing is asked twice.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::Instrument;
|
||||
|
||||
use crate::Ctx;
|
||||
use crate::db::{Ask, Unjudged};
|
||||
use crate::feed;
|
||||
|
||||
/// Items put to Jev a pass. A pass ends early once there are none left, so this only says how
|
||||
/// long the queue is held before people who got a key later are looked at.
|
||||
const BATCH: i64 = 50;
|
||||
/// Between passes when nothing wakes it: a scan or a new Signal does, so this is a backstop.
|
||||
const IDLE: std::time::Duration = std::time::Duration::from_secs(300);
|
||||
/// How sure Jev has to be that an item is about the keywords.
|
||||
const THRESHOLD: f64 = 0.5;
|
||||
/// Of an item's text, which is a podcast's whole show notes at times.
|
||||
const TEXT_CHARS: usize = 2000;
|
||||
|
||||
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||
loop {
|
||||
let done = tokio::select! {
|
||||
_ = stop.changed() => return,
|
||||
done = pass(&ctx) => done,
|
||||
};
|
||||
match done {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "Signals could not be judged"),
|
||||
}
|
||||
tokio::select! {
|
||||
_ = stop.changed() => return,
|
||||
_ = ctx.signals.notified() => {}
|
||||
_ = tokio::time::sleep(IDLE) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One batch. True when there may be more waiting.
|
||||
async fn pass(ctx: &Ctx) -> Result<bool> {
|
||||
let skip: Vec<i64> = ctx.signals_refused.lock().unwrap().iter().copied().collect();
|
||||
let items = ctx.db.unjudged(&skip, BATCH).await?;
|
||||
if items.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let span = tracing::info_span!("signals", entries = items.len());
|
||||
async {
|
||||
let mut refused = HashSet::new();
|
||||
let mut hits_for = std::collections::BTreeMap::<i64, usize>::new();
|
||||
let mut stopped = false;
|
||||
for item in &items {
|
||||
if refused.contains(&item.user_id) {
|
||||
continue;
|
||||
}
|
||||
match judge(ctx, item).await {
|
||||
Ok(verdicts) => {
|
||||
let hits = verdicts.iter().filter(|(_, h)| *h).count();
|
||||
if hits > 0 {
|
||||
*hits_for.entry(item.user_id).or_default() += hits;
|
||||
tracing::info!(ev = "signal", user = item.user_id, feed = %item.feed_id, new = hits, "an item fits a Signal");
|
||||
}
|
||||
ctx.db.record_verdicts(&item.feed_id, &item.guid, &verdicts).await?;
|
||||
}
|
||||
// A key TypeSafe will not take is not sent the rest of the queue. It is tried
|
||||
// again once its owner saves one (`web::patch_me`) or the daemon restarts.
|
||||
Err(e) if refuses_key(&e) => {
|
||||
tracing::warn!(user = item.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key");
|
||||
refused.insert(item.user_id);
|
||||
}
|
||||
// TypeSafe says the request itself is wrong, so every other item's would be too:
|
||||
// the rest wait for the next pass rather than each failing the same way (#257).
|
||||
Err(e) if malformed(&e) => {
|
||||
tracing::warn!(user = item.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signals request as malformed");
|
||||
stopped = true;
|
||||
break;
|
||||
}
|
||||
// Left unjudged, so it is asked again next pass.
|
||||
Err(e) => tracing::warn!(user = item.user_id, feed = %item.feed_id, error = %format!("{e:#}"), "Jev could not judge an item"),
|
||||
}
|
||||
}
|
||||
// Once a pass, not once an item: a page fetches its counts again on each.
|
||||
for (user, hits) in hits_for {
|
||||
ctx.out.emit(crate::ipc::Event::SignalHits { user, hits });
|
||||
}
|
||||
let some_refused = !refused.is_empty();
|
||||
ctx.signals_refused.lock().unwrap().extend(refused);
|
||||
// A pass that only failed would otherwise go straight round again on the same items, and
|
||||
// one TypeSafe refused as malformed would ask again at once, however full.
|
||||
anyhow::Ok(!stopped && (full(&items) || some_refused))
|
||||
}
|
||||
.instrument(span)
|
||||
.await
|
||||
}
|
||||
|
||||
/// A call TypeSafe answered with an error, and what it said: its body says why, where the status
|
||||
/// alone said only 422 (#257).
|
||||
#[derive(Debug)]
|
||||
struct Refused {
|
||||
status: u16,
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Refused {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(f, "TypeSafe answered {}: {}", self.status, self.body)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Refused {}
|
||||
|
||||
fn status(e: &anyhow::Error) -> Option<u16> {
|
||||
e.downcast_ref::<Refused>().map(|r| r.status)
|
||||
}
|
||||
|
||||
/// Whether the batch was as long as `unjudged` gives, so more may be waiting. Its limit is on
|
||||
/// rows, one for each item and Signal: with two Signals, 50 is 25 items, and comparing the items
|
||||
/// with 50 took every batch for the last, 25 items every 5 minutes (#260).
|
||||
fn full(items: &[Unjudged]) -> bool {
|
||||
items.iter().map(|i| i.signals.len()).sum::<usize>() as i64 >= BATCH
|
||||
}
|
||||
|
||||
fn refuses_key(e: &anyhow::Error) -> bool {
|
||||
matches!(status(e), Some(401 | 402 | 403))
|
||||
}
|
||||
|
||||
fn malformed(e: &anyhow::Error) -> bool {
|
||||
matches!(status(e), Some(400 | 422))
|
||||
}
|
||||
|
||||
async fn judge(ctx: &Ctx, item: &Unjudged) -> Result<Vec<(i64, bool)>> {
|
||||
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
|
||||
let span = tracing::info_span!(
|
||||
"jev",
|
||||
user = item.user_id,
|
||||
signal_count = item.signals.len(),
|
||||
"http.response.status_code" = tracing::field::Empty,
|
||||
error = tracing::field::Empty,
|
||||
);
|
||||
let answer: serde_json::Value = async {
|
||||
let r = ctx.client.post(url).bearer_auth(&item.key).json(&request(item)).timeout(feed::FEED_TIMEOUT).send().await?;
|
||||
let status = r.status().as_u16();
|
||||
tracing::Span::current().record("http.response.status_code", status);
|
||||
if !r.status().is_success() {
|
||||
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
|
||||
tracing::Span::current().record("error", body.as_str());
|
||||
return Err(Refused { status, body }.into());
|
||||
}
|
||||
anyhow::Ok(r.json().await?)
|
||||
}
|
||||
.instrument(span)
|
||||
.await?;
|
||||
// A question Jev left unanswered stays unjudged and is asked again.
|
||||
Ok(item.signals.iter().filter_map(|a| hit(&answer, a.id).map(|h| (a.id, h))).collect())
|
||||
}
|
||||
|
||||
/// The item as Jev sees it, as text, which is what its docs show: an object with an empty feed
|
||||
/// title in it sent a null. One yes-or-no question for each Signal, keyed by its id.
|
||||
fn request(item: &Unjudged) -> serde_json::Value {
|
||||
let text: String = text_of(item.description.as_deref().unwrap_or("")).chars().take(TEXT_CHARS).collect();
|
||||
let state = format!(
|
||||
"Feed: {}\nTitle: {}\n\n{text}",
|
||||
item.feed_title.as_deref().unwrap_or(""),
|
||||
item.title.as_deref().unwrap_or("")
|
||||
);
|
||||
let questions: serde_json::Map<String, serde_json::Value> =
|
||||
item.signals.iter().map(|a| (key(a.id), question(a))).collect();
|
||||
serde_json::json!({ "model": "jev-latest", "state": state, "questions": questions })
|
||||
}
|
||||
|
||||
fn key(id: i64) -> String {
|
||||
format!("signal_{id}")
|
||||
}
|
||||
|
||||
/// One subject, named by the Signal and described by its words and its owner's sentence. Asked
|
||||
/// whether an item was about any one of its words, AI Development (ai, development, programming)
|
||||
/// took a game's remaster at 0.90 and a C++ tutorial at 0.99; asked about the subject, 0.06 and
|
||||
/// 0.02, with AI code review still 0.94 (#259). `criteria` is an object of what true and false
|
||||
/// mean: a sentence on its own was refused with a 422 (#257).
|
||||
fn question(a: &Ask) -> serde_json::Value {
|
||||
let mut said = format!("The reader follows a subject they call \"{}\", described by these words: {}.", a.name, a.keywords.join(", "));
|
||||
if let Some(d) = &a.description {
|
||||
said.push_str(&format!(" In their own words: {d}"));
|
||||
}
|
||||
serde_json::json!({
|
||||
"type": "noul",
|
||||
"instructions": format!("{said} Is this item about that subject?"),
|
||||
"criteria": {
|
||||
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
|
||||
"false": "It is about something else, or touches the subject only in passing.",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether Jev says the item fits Signal `id`; None when the answer does not say.
|
||||
fn hit(answer: &serde_json::Value, id: i64) -> Option<bool> {
|
||||
answer["answers"][key(id)]["noul"].as_f64().map(|p| p >= THRESHOLD)
|
||||
}
|
||||
|
||||
/// An icon for a Signal, picked by Jev from the ones the page found for its words (#263): Jev
|
||||
/// chooses among options and cannot name one, so the page searches Font Awesome, whose index is
|
||||
/// ipx-web's, and sends the likeliest. On the server's key, not the Signal owner's: it is the
|
||||
/// server's page doing it, once a Signal.
|
||||
pub async fn pick_icon(ctx: &Ctx, key: &str, name: &str, words: &[String], description: Option<&str>, candidates: &[(String, String)]) -> Result<String> {
|
||||
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
|
||||
let span = tracing::info_span!("jev_icon", candidates = candidates.len(), "http.response.status_code" = tracing::field::Empty);
|
||||
async {
|
||||
let r = ctx.client.post(url).bearer_auth(key).json(&icon_request(name, words, description, candidates)).timeout(feed::FEED_TIMEOUT).send().await?;
|
||||
let status = r.status().as_u16();
|
||||
tracing::Span::current().record("http.response.status_code", status);
|
||||
if !r.status().is_success() {
|
||||
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
|
||||
return Err(Refused { status, body }.into());
|
||||
}
|
||||
let answer: serde_json::Value = r.json().await?;
|
||||
chosen(&answer, candidates).ok_or_else(|| anyhow::anyhow!("Jev chose no icon offered: {answer}"))
|
||||
}
|
||||
.instrument(span)
|
||||
.await
|
||||
}
|
||||
|
||||
fn icon_request(name: &str, words: &[String], description: Option<&str>, candidates: &[(String, String)]) -> serde_json::Value {
|
||||
let mut state = format!("Signal: {name}\nListens for: {}", words.join(", "));
|
||||
if let Some(d) = description {
|
||||
state.push_str(&format!("\nIn their words: {d}"));
|
||||
}
|
||||
let criteria: serde_json::Map<String, serde_json::Value> =
|
||||
candidates.iter().map(|(id, label)| (id.clone(), label.clone().into())).collect();
|
||||
serde_json::json!({
|
||||
"model": "jev-latest",
|
||||
"state": state,
|
||||
"questions": { "icon": {
|
||||
"type": "choice",
|
||||
"instructions": "Which icon best stands for this subject in a list of the reader's topics?",
|
||||
"criteria": criteria,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
/// Jev's choice, only if it is one it was offered.
|
||||
fn chosen(answer: &serde_json::Value, candidates: &[(String, String)]) -> Option<String> {
|
||||
let c = answer["answers"]["icon"]["choice"].as_str()?;
|
||||
candidates.iter().find(|(id, _)| id == c).map(|(id, _)| id.clone())
|
||||
}
|
||||
|
||||
/// Show notes are HTML: Jev is given their words, and an image's alt and title text, which is all
|
||||
/// the words some posts have. xkcd's is a comic and its hover text; dropping the tag whole sent Jev
|
||||
/// "Ground Effect" alone, an F1 aerodynamics term, and the comic went into Formula 1 (#267).
|
||||
fn text_of(html: &str) -> String {
|
||||
let mut out = String::with_capacity(html.len());
|
||||
let mut tag: Option<String> = None;
|
||||
for c in html.chars() {
|
||||
match (&mut tag, c) {
|
||||
(None, '<') => tag = Some(String::new()),
|
||||
(Some(t), '>') => {
|
||||
let mut said: Vec<&str> = Vec::new();
|
||||
for name in ["alt", "title"] {
|
||||
if let Some(v) = attr(t, name).filter(|v| !v.trim().is_empty() && !said.contains(v)) {
|
||||
said.push(v);
|
||||
}
|
||||
}
|
||||
for v in said {
|
||||
out.push(' ');
|
||||
out.push_str(v);
|
||||
}
|
||||
out.push(' ');
|
||||
tag = None;
|
||||
}
|
||||
(Some(t), c) => t.push(c),
|
||||
(None, c) => out.push(c),
|
||||
}
|
||||
}
|
||||
out.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// A quoted attribute's value in a tag's text, `alt="..."` or `alt='...'`.
|
||||
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
|
||||
let lower = tag.to_ascii_lowercase();
|
||||
for q in ['"', '\''] {
|
||||
let key = format!(" {name}={q}");
|
||||
if let Some(at) = lower.find(&key) {
|
||||
let rest = &tag[at + key.len()..];
|
||||
return rest.find(q).map(|end| &rest[..end]);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_noul_of_a_half_or_more_is_a_fit() {
|
||||
// As TypeSafe answered on 2026-10-16.
|
||||
let answer = serde_json::json!({ "model": "jev-1.13.0", "answers": {
|
||||
"signal_3": { "type": "noul", "noul": 0.5 }, "signal_4": { "type": "noul", "noul": 0.49 } } });
|
||||
assert_eq!(hit(&answer, 3), Some(true));
|
||||
assert_eq!(hit(&answer, 4), Some(false));
|
||||
assert_eq!(hit(&answer, 5), None, "a question not answered is asked again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_icon_is_one_jev_was_offered() {
|
||||
let offered = vec![("solid/robot".to_string(), "Robot: ai, bot".to_string()), ("solid/laptop-code".to_string(), "Laptop Code".to_string())];
|
||||
let r = icon_request("AI Development", &["ai".into()], Some("AI for writing software."), &offered);
|
||||
assert_eq!(r["questions"]["icon"]["type"], "choice");
|
||||
assert_eq!(r["questions"]["icon"]["criteria"]["solid/robot"], "Robot: ai, bot");
|
||||
assert!(r["state"].as_str().unwrap().contains("In their words: AI for writing software."));
|
||||
// As TypeSafe answered on 2026-10-16.
|
||||
let answer = serde_json::json!({ "answers": { "icon": { "type": "choice", "choice": "solid/laptop-code", "confidence": 0.34 } } });
|
||||
assert_eq!(chosen(&answer, &offered).as_deref(), Some("solid/laptop-code"));
|
||||
let stray = serde_json::json!({ "answers": { "icon": { "choice": "solid/skull" } } });
|
||||
assert_eq!(chosen(&stray, &offered), None, "never one it was not offered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_images_words_are_read_with_the_text() {
|
||||
let xkcd = r#"<img src="https://imgs.xkcd.com/comics/ground_effect.png" title="Runners wear sneakers." alt="Runners wear sneakers." referrerpolicy="no-referrer">"#;
|
||||
assert_eq!(text_of(xkcd), "Runners wear sneakers.", "alt and title once, when they say the same");
|
||||
assert_eq!(text_of("<p>Qualifying <b>report</b></p><img alt='A grid'>"), "Qualifying report A grid");
|
||||
assert_eq!(text_of(r#"<img src="x.png" alt="">"#), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_is_full_by_its_rows_not_its_items() {
|
||||
let item = |n: usize| Unjudged {
|
||||
user_id: 1, key: "k".into(), feed_id: "f".into(), guid: "g".into(), feed_title: None, title: None, description: None,
|
||||
signals: (0..n as i64).map(|id| Ask { id, name: "s".into(), keywords: vec![], description: None }).collect(),
|
||||
};
|
||||
assert!(full(&(0..25).map(|_| item(2)).collect::<Vec<_>>()), "25 items of two Signals each is the 50 asked for");
|
||||
assert!(!full(&(0..24).map(|_| item(2)).collect::<Vec<_>>()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_signal_is_its_own_question_on_the_items_words() {
|
||||
let item = Unjudged {
|
||||
user_id: 1,
|
||||
key: "k".into(),
|
||||
feed_id: "f".into(),
|
||||
guid: "g".into(),
|
||||
feed_title: Some("Pit Lane".into()),
|
||||
title: Some("Monaco".into()),
|
||||
description: Some("<p>Qualifying <b>report</b></p>".into()),
|
||||
signals: vec![
|
||||
Ask { id: 3, name: "F1".into(), keywords: vec!["formula 1".into(), "f1".into()], description: None },
|
||||
Ask { id: 4, name: "Food".into(), keywords: vec!["cooking".into()], description: Some("Recipes, not restaurants.".into()) },
|
||||
],
|
||||
};
|
||||
let r = request(&item);
|
||||
assert_eq!(r["state"], "Feed: Pit Lane\nTitle: Monaco\n\nQualifying report");
|
||||
let q = &r["questions"]["signal_3"];
|
||||
assert_eq!(q["type"], "noul");
|
||||
let says = q["instructions"].as_str().unwrap();
|
||||
assert!(says.contains("\"F1\"") && says.contains("formula 1, f1"), "one subject, by its name and its words: {says}");
|
||||
assert!(!says.contains("own words"), "no sentence, none said");
|
||||
assert!(q["criteria"]["true"].is_string() && q["criteria"]["false"].is_string(), "TypeSafe takes an object, not a sentence");
|
||||
assert!(r["questions"]["signal_4"]["instructions"].as_str().unwrap().contains("In their own words: Recipes, not restaurants."));
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,10 @@ pub fn router(state: WebState) -> Router {
|
||||
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
|
||||
.route("/api/feeds/{id}/entries", get(entries))
|
||||
.route("/api/entries", get(all_entries))
|
||||
.route("/api/signals", get(list_signals).post(add_signal))
|
||||
.route("/api/signals/icon", post(pick_signal_icon))
|
||||
.route("/api/signals/{id}", patch(patch_signal).delete(remove_signal))
|
||||
.route("/api/signals/{id}/entries", get(signal_entries))
|
||||
.route("/api/entries/{feed_id}/{guid}", get(one_entry))
|
||||
.route("/api/entries/{feed_id}/{guid}/flags", post(set_flags))
|
||||
.route("/api/entries/{feed_id}/{guid}/position", post(set_position))
|
||||
@@ -382,6 +386,7 @@ async fn me(
|
||||
Json(serde_json::json!({
|
||||
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked,
|
||||
"deadair_saved": saved, "theme": theme, "version": env!("CARGO_PKG_VERSION"),
|
||||
"typesafe_key_set": state.ctx.db.typesafe_key_set(user.id).await.unwrap_or(false),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -389,6 +394,9 @@ async fn me(
|
||||
struct MePatch {
|
||||
/// Words that hide an item in every feed you read.
|
||||
blocked: Option<Vec<String>>,
|
||||
/// The TypeSafe key Signals are judged on: a key saves it, null or empty forgets it.
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
typesafe_key: Option<Option<String>>,
|
||||
}
|
||||
|
||||
/// Saves the block list for every feed. The theme is not the account's: each browser keeps its
|
||||
@@ -401,9 +409,189 @@ async fn patch_me(
|
||||
if let Some(words) = body.blocked {
|
||||
state.ctx.db.set_blocklist(user.id, "", &clean_words(words)?).await?;
|
||||
}
|
||||
if let Some(key) = body.typesafe_key {
|
||||
let key = key.map(|k| k.trim().to_owned()).filter(|k| !k.is_empty());
|
||||
if key.as_ref().is_some_and(|k| k.len() > 500 || !k.bytes().all(|b| b.is_ascii_graphic())) {
|
||||
return Err(ApiError::bad_request("that does not look like a TypeSafe key"));
|
||||
}
|
||||
state.ctx.db.set_typesafe_key(user.id, key.as_deref()).await?;
|
||||
state.ctx.signals_refused.lock().unwrap().remove(&user.id);
|
||||
state.ctx.signals.notify_one();
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ---- Signals (#254): each person's own, so each route finds the Signal by its owner ----
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SignalBody {
|
||||
name: Option<String>,
|
||||
keywords: Option<Vec<String>>,
|
||||
/// What its owner means by it, in their words: a string sets it, null or empty clears it.
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
description: Option<Option<String>>,
|
||||
/// A Font Awesome Free icon, `solid/robot`: a string sets it, null or empty clears it.
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
icon: Option<Option<String>>,
|
||||
}
|
||||
|
||||
/// The page puts the name into an address (`/icons/<style>/<name>.svg`), so nothing but a style
|
||||
/// and a plain name gets through.
|
||||
fn signal_icon(i: Option<String>) -> Result<Option<String>, ApiError> {
|
||||
let Some(i) = i.filter(|i| !i.is_empty()) else { return Ok(None) };
|
||||
let ok = i.split_once('/').is_some_and(|(style, name)| {
|
||||
["solid", "regular", "brands"].contains(&style)
|
||||
&& (1..=64).contains(&name.len())
|
||||
&& name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
});
|
||||
if !ok {
|
||||
return Err(ApiError::bad_request("an icon is a Font Awesome Free one, such as solid/robot"));
|
||||
}
|
||||
Ok(Some(i))
|
||||
}
|
||||
|
||||
fn signal_description(d: Option<String>) -> Result<Option<String>, ApiError> {
|
||||
let d = d.map(|d| d.trim().to_owned()).filter(|d| !d.is_empty());
|
||||
if d.as_ref().is_some_and(|d| d.chars().count() > 500) {
|
||||
return Err(ApiError::bad_request("a Signal's description is up to 500 characters"));
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
fn signal_name(name: &str) -> Result<String, ApiError> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name.chars().count() > 100 {
|
||||
return Err(ApiError::bad_request("a Signal needs a name of up to 100 characters"));
|
||||
}
|
||||
Ok(name.to_owned())
|
||||
}
|
||||
|
||||
fn signal_words(words: Vec<String>) -> Result<Vec<String>, ApiError> {
|
||||
let words = clean_words(words)?;
|
||||
// Each is in the question Jev is asked about every item.
|
||||
if words.is_empty() || words.len() > 50 {
|
||||
return Err(ApiError::bad_request("a Signal needs between 1 and 50 keywords"));
|
||||
}
|
||||
Ok(words)
|
||||
}
|
||||
|
||||
async fn list_signals(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
) -> Result<Json<Vec<crate::db::SignalRow>>, ApiError> {
|
||||
Ok(Json(state.ctx.db.signals(user.id).await?))
|
||||
}
|
||||
|
||||
async fn add_signal(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
Json(body): Json<SignalBody>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let name = signal_name(body.name.as_deref().unwrap_or(""))?;
|
||||
let words = signal_words(body.keywords.unwrap_or_default())?;
|
||||
let description = signal_description(body.description.flatten())?;
|
||||
let icon = signal_icon(body.icon.flatten())?;
|
||||
let id = state.ctx.db.add_signal(user.id, &name, &words, description.as_deref()).await?;
|
||||
if icon.is_some() {
|
||||
let change = crate::db::SignalChange { icon: Some(icon.as_deref()), ..Default::default() };
|
||||
state.ctx.db.set_signal(user.id, id, change).await?;
|
||||
}
|
||||
state.ctx.signals.notify_one();
|
||||
Ok(Json(serde_json::json!({ "id": id })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IconAsk {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
keywords: Vec<String>,
|
||||
description: Option<String>,
|
||||
/// `[style/name, label]` for each icon the page found for the Signal's words.
|
||||
candidates: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Jev's pick of an icon for a Signal, on the server's TypeSafe key (#263). Answers `{ "icon": ... }`,
|
||||
/// or 404 when the server has no key.
|
||||
async fn pick_signal_icon(
|
||||
State(state): State<WebState>,
|
||||
_user: crate::db::User,
|
||||
Json(body): Json<IconAsk>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let Ok(key) = std::env::var("TYPESAFE_KEY") else {
|
||||
return Err(ApiError::not_found("picking an icon is not set up on this server"));
|
||||
};
|
||||
let name = signal_name(&body.name)?;
|
||||
let words = clean_words(body.keywords)?;
|
||||
let description = signal_description(body.description)?;
|
||||
// Each one is in the question, and comes back as the Signal's icon: only real icon names.
|
||||
let mut candidates: Vec<(String, String)> = Vec::new();
|
||||
for (id, label) in body.candidates {
|
||||
let id = signal_icon(Some(id))?.ok_or_else(|| ApiError::bad_request("an empty icon"))?;
|
||||
if !candidates.iter().any(|(c, _)| *c == id) {
|
||||
candidates.push((id, label.chars().take(200).collect()));
|
||||
}
|
||||
}
|
||||
if candidates.is_empty() || candidates.len() > 60 {
|
||||
return Err(ApiError::bad_request("between 1 and 60 icons to choose from"));
|
||||
}
|
||||
let icon = crate::signals::pick_icon(&state.ctx, &key, &name, &words, description.as_deref(), &candidates)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %format!("{e:#}"), "Jev could not pick an icon");
|
||||
ApiError::bad_gateway("no icon could be picked just now")
|
||||
})?;
|
||||
Ok(Json(serde_json::json!({ "icon": icon })))
|
||||
}
|
||||
|
||||
async fn patch_signal(
|
||||
State(state): State<WebState>,
|
||||
Path(id): Path<i64>,
|
||||
user: crate::db::User,
|
||||
Json(body): Json<SignalBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let name = body.name.as_deref().map(signal_name).transpose()?;
|
||||
let words = body.keywords.map(signal_words).transpose()?;
|
||||
let description = body.description.map(signal_description).transpose()?;
|
||||
let icon = body.icon.map(signal_icon).transpose()?;
|
||||
let asks_again = name.is_some() || words.is_some() || description.is_some();
|
||||
let change = crate::db::SignalChange {
|
||||
name: name.as_deref(),
|
||||
words: words.as_deref(),
|
||||
description: description.as_ref().map(|d| d.as_deref()),
|
||||
icon: icon.as_ref().map(|i| i.as_deref()),
|
||||
};
|
||||
if !state.ctx.db.set_signal(user.id, id, change).await? {
|
||||
return Err(ApiError::not_found("no such Signal"));
|
||||
}
|
||||
if asks_again {
|
||||
state.ctx.signals.notify_one();
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn remove_signal(
|
||||
State(state): State<WebState>,
|
||||
Path(id): Path<i64>,
|
||||
user: crate::db::User,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
if !state.ctx.db.remove_signal(user.id, id).await? {
|
||||
return Err(ApiError::not_found("no such Signal"));
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn signal_entries(
|
||||
State(state): State<WebState>,
|
||||
Path(id): Path<i64>,
|
||||
user: crate::db::User,
|
||||
Query(page): Query<Page>,
|
||||
) -> Result<Json<EntryPage>, ApiError> {
|
||||
if state.ctx.db.signal(user.id, id).await?.is_none() {
|
||||
return Err(ApiError::not_found("no such Signal"));
|
||||
}
|
||||
entry_page(&state, user.id, None, Some(id), &page).await
|
||||
}
|
||||
|
||||
fn theme_ok(theme: &str, mode: &str) -> bool {
|
||||
(1..=20).contains(&theme.len())
|
||||
&& theme.bytes().all(|b| b.is_ascii_lowercase())
|
||||
@@ -770,11 +958,11 @@ async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>)
|
||||
.schedule
|
||||
.as_deref()
|
||||
.and_then(crate::config::parse_interval),
|
||||
every_mins: crate::due_after(&cfg, feed, ttl_mins, None, 0) / 60,
|
||||
every_mins: crate::due_after(&cfg, feed, ttl_mins, None, None, 0) / 60,
|
||||
last_checked: s.last_checked,
|
||||
next_check: s
|
||||
.last_checked
|
||||
.map(|t| t + crate::due_after(&cfg, feed, ttl_mins, s.error_since, t) as i64),
|
||||
.map(|t| t + crate::due_after(&cfg, feed, ttl_mins, s.error_since, s.same_since, t) as i64),
|
||||
failing: s
|
||||
.error_since
|
||||
.filter(|since| crate::db::now() - since >= FLAG_AFTER_SECS)
|
||||
@@ -831,14 +1019,17 @@ fn dir_error(e: crate::directory::DirError) -> ApiError {
|
||||
/// The directory's listings as this person sees them: which are theirs already, matched by
|
||||
/// address, since the directory and this server name a feed differently.
|
||||
async fn rows_for(state: &WebState, user_id: i64, listed: Vec<ipx_directory::Listing>) -> Result<Vec<PopularRow>> {
|
||||
// A step each, as the feed list has: a Directory page of 983 ms showed 19 under spans (#250).
|
||||
let mine: std::collections::HashSet<String> =
|
||||
state.ctx.db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||
state.ctx.db.subscriptions_for(user_id).instrument(step("subscriptions")).await?.into_iter().map(|s| s.feed_id).collect();
|
||||
let here: std::collections::HashMap<String, String> = crate::subscriptions(&state.ctx)
|
||||
.instrument(step("catalogue"))
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|s| mine.contains(&s.id))
|
||||
.filter_map(|s| ipx_directory::store::normalise(&s.cfg.url).map(|u| (u, s.id)))
|
||||
.collect();
|
||||
let _matching = step("match").entered();
|
||||
Ok(listed
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
@@ -1056,6 +1247,14 @@ impl ApiError {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
}
|
||||
}
|
||||
|
||||
/// A service ipx asks on someone's behalf failed them.
|
||||
fn bad_gateway(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
error: anyhow::Error::msg(msg.into()),
|
||||
status: StatusCode::BAD_GATEWAY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
@@ -1094,6 +1293,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signal_icon_is_a_style_and_a_plain_name() {
|
||||
assert_eq!(signal_icon(Some("solid/robot".into())).ok().flatten().as_deref(), Some("solid/robot"));
|
||||
assert_eq!(signal_icon(Some("brands/apple".into())).ok().flatten().as_deref(), Some("brands/apple"));
|
||||
assert!(matches!(signal_icon(Some(String::new())), Ok(None)), "empty clears it");
|
||||
// It becomes part of an address and a style attribute on the page.
|
||||
for bad in ["solid/../../api/me", "light/robot", "solid/Robot", "solid/x');background:url(x", "robot", "solid/"] {
|
||||
assert!(signal_icon(Some(bad.into())).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_plain_theme_name_is_handed_on() {
|
||||
assert!(theme_ok("dracula", "dark") && theme_ok("nordic", "auto"));
|
||||
@@ -1109,8 +1319,9 @@ mod tests {
|
||||
let u = crate::db::User { id: 1, name: "dee".into(), pass_hash: None, is_admin: false, created: None, last_login: None };
|
||||
let e = |ev: &str| ClientEvent {
|
||||
ev: ev.into(), msg: Some("x".repeat(10_000)), src: None, line: None, feed: None, enclosure: None, deadair: None, ms: Some(5),
|
||||
filter: Some("unread".into()),
|
||||
};
|
||||
for ev in ["page_error", "rejection", "player_error", "play_start", "stall"] {
|
||||
for ev in ["page_error", "rejection", "player_error", "play_start", "stall", "list_shown"] {
|
||||
assert_eq!(client_event(u.clone(), Json(e(ev))).await, StatusCode::NO_CONTENT, "{ev}");
|
||||
}
|
||||
assert_eq!(client_event(u, Json(e("anything"))).await, StatusCode::BAD_REQUEST);
|
||||
@@ -1227,7 +1438,7 @@ async fn entries(
|
||||
Query(page): Query<Page>,
|
||||
) -> Result<Json<EntryPage>, ApiError> {
|
||||
subscribed(&state, &user, &id).await?;
|
||||
entry_page(&state, user.id, Some(&id), &page).await
|
||||
entry_page(&state, user.id, Some(&id), None, &page).await
|
||||
}
|
||||
|
||||
/// A feed's items and files are for the people who subscribe to it (#129). Anyone signed in
|
||||
@@ -1248,24 +1459,26 @@ async fn all_entries(
|
||||
user: crate::db::User,
|
||||
Query(page): Query<Page>,
|
||||
) -> Result<Json<EntryPage>, ApiError> {
|
||||
entry_page(&state, user.id, None, &page).await
|
||||
entry_page(&state, user.id, None, None, &page).await
|
||||
}
|
||||
|
||||
/// One feed's page of items, or every subscribed feed's when `feed` is None.
|
||||
/// One feed's page of items, or every subscribed feed's when `feed` is None, or a Signal's.
|
||||
async fn entry_page(
|
||||
state: &WebState,
|
||||
user_id: i64,
|
||||
feed: Option<&str>,
|
||||
signal: Option<i64>,
|
||||
page: &Page,
|
||||
) -> Result<Json<EntryPage>, ApiError> {
|
||||
let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all"));
|
||||
let search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
|
||||
let db = &state.ctx.db;
|
||||
let order = crate::db::order_sql(page.sort.as_deref().unwrap_or("published"), page.dir.as_deref().unwrap_or("desc"));
|
||||
let mut rows = db
|
||||
.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)
|
||||
.instrument(step("entries_in"))
|
||||
.await?;
|
||||
let limit = page.limit.clamp(1, 200);
|
||||
let mut rows = match signal {
|
||||
Some(id) => db.signal_entries(user_id, id, filter, search, page.offset, limit, &order).instrument(step("entries_in")).await?,
|
||||
None => db.entries_in(user_id, feed, filter, search, page.offset, limit, &order).instrument(step("entries_in")).await?,
|
||||
};
|
||||
step("sanitize").in_scope(|| {
|
||||
let mut sanitizer = feed_sanitizer();
|
||||
for row in &mut rows {
|
||||
@@ -1275,7 +1488,10 @@ async fn entry_page(
|
||||
}
|
||||
});
|
||||
crate::deadair::for_reader(&state.ctx, user_id, &mut rows).instrument(step("deadair")).await?;
|
||||
let total = db.count_in(user_id, feed, filter, search).instrument(step("count_in")).await?;
|
||||
let total = match signal {
|
||||
Some(id) => db.signal_count(user_id, id, filter, search).instrument(step("count_in")).await?,
|
||||
None => db.count_in(user_id, feed, filter, search).instrument(step("count_in")).await?,
|
||||
};
|
||||
Ok(Json(EntryPage { total, entries: rows }))
|
||||
}
|
||||
|
||||
@@ -1907,7 +2123,7 @@ async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Respon
|
||||
Err(e) => {
|
||||
let why = format!("{e:#}");
|
||||
// The access line says only that it failed, not which image or why (#169).
|
||||
tracing::warn!(url = %q.u, error = %why, "could not fetch artwork");
|
||||
tracing::warn!(url = %crate::feed::redact(&q.u), error = %crate::feed::redact_text(&why), "could not fetch artwork");
|
||||
crate::art::fail(&q.u, &why);
|
||||
(StatusCode::BAD_GATEWAY, why).into_response()
|
||||
}
|
||||
@@ -2252,12 +2468,14 @@ struct ClientEvent {
|
||||
enclosure: Option<i64>,
|
||||
deadair: Option<bool>,
|
||||
ms: Option<u64>,
|
||||
/// The list's filter, for `list_shown`: all, unread, flagged.
|
||||
filter: Option<String>,
|
||||
}
|
||||
|
||||
async fn client_event(user: crate::db::User, Json(e): Json<ClientEvent>) -> StatusCode {
|
||||
// From the browser: only the kinds the page sends, and nothing long enough to fill the log.
|
||||
let cut = |s: &Option<String>| s.as_deref().map(|s| s.chars().take(500).collect::<String>());
|
||||
let (msg, src, feed) = (cut(&e.msg), cut(&e.src), cut(&e.feed));
|
||||
let (msg, src, feed, filter) = (cut(&e.msg), cut(&e.src), cut(&e.feed), cut(&e.filter));
|
||||
let (ev, line, enclosure, deadair, ms, user) = (e.ev.as_str(), e.line, e.enclosure, e.deadair, e.ms, user.name.as_str());
|
||||
match ev {
|
||||
"page_error" | "rejection" | "player_error" => tracing::warn!(
|
||||
@@ -2268,6 +2486,11 @@ async fn client_event(user: crate::db::User, Json(e): Json<ClientEvent>) -> Stat
|
||||
target: "ipx::client", ev, ms, feed, enclosure, deadair, user,
|
||||
"{user}'s page: {ev} {} ms", ms.unwrap_or(0)
|
||||
),
|
||||
// From a click to the list drawn (#246).
|
||||
"list_shown" => tracing::info!(
|
||||
target: "ipx::client", ev, ms, feed, filter, user,
|
||||
"{user}'s page: list_shown {} ms", ms.unwrap_or(0)
|
||||
),
|
||||
_ => return StatusCode::BAD_REQUEST,
|
||||
}
|
||||
StatusCode::NO_CONTENT
|
||||
|
||||
@@ -22,12 +22,6 @@ services:
|
||||
- ./config:/config # config.toml, written on first start
|
||||
- ./data:/data # state.db
|
||||
- ./downloads:/downloads
|
||||
healthcheck:
|
||||
test: ["CMD", "ipx", "status"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
ipx-web:
|
||||
image: ipx-web
|
||||
@@ -47,8 +41,3 @@ services:
|
||||
- "8099:8080" # the web UI
|
||||
depends_on:
|
||||
- ipx
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/favicon.png"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -41,6 +41,7 @@ id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web]
|
||||
| `crates/ipx/src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
|
||||
| `crates/ipx/src/directory.rs` | Asking the directory service, and sharing this server's public feeds with it | — |
|
||||
| `crates/ipx/src/metrics.rs` | `/metrics` for Prometheus | — |
|
||||
| `crates/ipx/src/signals.rs` | Signals: asks Jev, on each person's TypeSafe key, which of their items fit their Signals | — |
|
||||
| `crates/directory/src/lib.rs` | The directory's HTTP API, its token check, the refresher | — |
|
||||
| `crates/directory/src/store.rs` | The directory's database: feeds, snapshots, blocked | — |
|
||||
| `crates/directory/src/config.rs` | The directory's config file: its tokens and their access | — |
|
||||
@@ -60,7 +61,10 @@ and the files, and no page.
|
||||
## A scan
|
||||
|
||||
1. Skip the feed unless `last_checked + max(schedule, ttl)` has passed (`--force` ignores this).
|
||||
2. Conditional GET with the stored `ETag` / `Last-Modified`. `304` ends it there.
|
||||
Without a schedule of its own, a feed whose body has been the same for a while waits longer:
|
||||
a twelfth of how long it has been the same, up to four hours (#242).
|
||||
2. Conditional GET with the stored `ETag` / `Last-Modified`. `304` ends it there, and so does a
|
||||
body identical to the last one (`body_hash`), from a server that ignores the validators.
|
||||
3. Sniff the body: RSS, then Atom, then OPML. An OPML is a live subscription — its feeds are
|
||||
re-derived into the database each scan, never written to config.toml. A Patreon creator link
|
||||
(a token, no `show=`) with more than one show is treated the same way, before any fetch: its
|
||||
@@ -81,7 +85,8 @@ and the files, and no page.
|
||||
|
||||
```
|
||||
feeds id, url, title, image, category, description, etag, last_modified,
|
||||
last_checked, ttl_mins, last_error, orphaned, group_id, managed
|
||||
last_checked, ttl_mins, last_error, orphaned, group_id, managed,
|
||||
body_hash, same_since
|
||||
entries feed_id, guid, title, link, published, description, first_seen,
|
||||
image, duration, episode, season PK (feed_id, guid)
|
||||
enclosures id, feed_id, guid, url UNIQUE, mime, length, path, state,
|
||||
@@ -92,8 +97,17 @@ subscriptions user_id, feed_id, keywords, auto_download, allow_explicit,
|
||||
max_new_per_check PK (user_id, feed_id)
|
||||
entry_state user_id, feed_id, guid, read, flagged, position
|
||||
PK (user_id, feed_id, guid)
|
||||
signals id, user_id, name, keywords
|
||||
signal_verdicts signal_id, feed_id, guid, hit PK (signal_id, feed_id, guid)
|
||||
```
|
||||
|
||||
A Signal (#254, [signals.md](signals.md)) is a feed someone makes from their own subscriptions. `signals::run`, a task of its
|
||||
own beside DeadAir's, woken by a scan with something new or a Signal made or changed, asks Jev
|
||||
about each item of the last 30 days that has no verdict yet for one of its owner's Signals: one
|
||||
call an item, a noul question a Signal, on the owner's `users.typesafe_key`. Every answer is kept,
|
||||
fitting or not, so nothing is asked twice; new keywords drop the Signal's verdicts. A Signal's
|
||||
items are `entries_where` with an `EXISTS` on its hits, so read state is the item's own.
|
||||
|
||||
Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position` columns from
|
||||
before accounts; two bugs came from queries still reading them, and they were dropped in 0.5.
|
||||
|
||||
|
||||
@@ -52,7 +52,10 @@ art_cache_mb = 500 # artwork kept on disk. 0 = none
|
||||
database as described above; `download_dir`, `socket` and `organize` stay in config.toml.
|
||||
|
||||
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
|
||||
be polled *less* often, and a per-feed `schedule` overrides both. A feed that keeps failing
|
||||
be polled *less* often, and a per-feed `schedule` overrides both. Without a schedule of its own,
|
||||
a feed that sends the same body check after check (Patreon, Substack, which ignore "not
|
||||
modified") is checked less often the longer it stays the same, up to every four hours, and is
|
||||
back on schedule once it changes. A feed that keeps failing
|
||||
waits as long as it has been failing before the next try, up to a day, and is back on schedule
|
||||
after its first success. A moment's failure to look its host up does not count: the network, not
|
||||
the feed, failed, and it is tried again in ten minutes. Admin-only from the UI.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
`ipx-directory` is a catalogue of public podcasts and blogs that ipx servers share (#204). An ipx
|
||||
server sends it the public feeds its people subscribe to, and how many of them do. The directory
|
||||
fetches each feed's title, artwork, category, description and latest twenty items every six hours,
|
||||
fetches each feed's title, artwork, category, description and latest twenty items every twelve hours,
|
||||
and any server holding a token can browse it. It never downloads a file, and it never stores
|
||||
one's address: every ipx server still fetches its own feeds.
|
||||
|
||||
@@ -78,6 +78,11 @@ with one name) leaves the tokens as they were, and the log says why.
|
||||
|
||||
```toml
|
||||
bind = "0.0.0.0:8100"
|
||||
# Prometheus metrics, on a port of their own and not the one the tunnel publishes. Empty, or
|
||||
# left out, serves none.
|
||||
metrics_bind = "0.0.0.0:9188"
|
||||
# Apple Podcasts' top charts for this country, weekly (see below). Empty, or left out: none.
|
||||
apple_country = "ca"
|
||||
# The environment variable holding the database URL: postgres://... or sqlite://...
|
||||
database_url_env = "DIRECTORY_DATABASE_URL"
|
||||
|
||||
@@ -97,6 +102,23 @@ production does with `deployment.environment.name=production`. Its version is in
|
||||
`crates/directory/Cargo.toml`, logged at start and answered by `ipx-directory --version`; its
|
||||
changelog is `crates/directory/CHANGELOG.md`.
|
||||
|
||||
## Apple Podcasts
|
||||
|
||||
With `apple_country` set, the directory takes Apple Podcasts' top charts for that country once a
|
||||
week (#252): the top 100 overall and the top 50 of each of Apple's categories, about a thousand
|
||||
shows. Apple's lookup API gives each one's RSS address, and they go in as a snapshot of their own,
|
||||
`apple-podcasts`, with no subscribers: fetched, sorted into categories and listed like any other
|
||||
feed, while the most subscribed stay what the servers sharing here subscribe to. A show Apple drops
|
||||
from its charts leaves the directory at the next sync, unless a server shares it. An admin can drop
|
||||
the snapshot as any server's (`DELETE /v1/instances/apple-podcasts`); the next sync brings it back.
|
||||
No token may be named `apple-podcasts`.
|
||||
|
||||
The same sync compares Apple's list of categories with the one ipx keeps (`CATEGORIES` in
|
||||
`crates/feed`) and logs a warning naming what differs, for whoever checks the log to update it.
|
||||
It runs at start when the last sync is a week old, and a failed one tries again an hour later. Each
|
||||
sync is a trace (`apple_sync`, a span for each of Apple's answers), and a line `Apple Podcasts
|
||||
synced` says how many shows came and went in.
|
||||
|
||||
## Running one
|
||||
|
||||
```yaml
|
||||
@@ -110,8 +132,6 @@ services:
|
||||
- "8100:8100"
|
||||
volumes:
|
||||
- ./directory:/config:ro # directory.toml
|
||||
healthcheck:
|
||||
test: ["CMD", "ipx-directory", "health"]
|
||||
```
|
||||
|
||||
## What it will not hold
|
||||
|
||||
368
docs/signals.md
Normal file
368
docs/signals.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# Signals
|
||||
|
||||
A Signal is a feed someone makes from their own subscriptions (#254). They give it a name, the
|
||||
words to listen for (`formula 1`, `f1`) and, when those are not enough, a sentence saying what they
|
||||
mean (#259). TypeSafe's Jev reads each item of the feeds they subscribe to and picks out the ones
|
||||
about that subject: the name, the words and the sentence together, not any one word. Those items show in the Signal as well as in their
|
||||
own feed. The rest stays noise, where it was.
|
||||
|
||||
Each person brings their own TypeSafe key, entered in Settings. Nothing is judged for someone
|
||||
without one. Jev is asked once for each item, on that person's key, with one question for each of
|
||||
their Signals that has not heard about the item yet.
|
||||
|
||||
## What someone sees
|
||||
|
||||
- **The sidebar.** A Signals folder comes first in the feed list, under All Subscriptions (#255,
|
||||
#256). It is closed or open as an OPML's folder is, remembered in each browser. Its row is like
|
||||
any folder's (#265); New signal is the + on the folder's page. Inside the folder are the
|
||||
person's Signals, each with its items and unread counted under its name.
|
||||
- **An icon of its own** (#261), chosen in the Signal's dialog from all of Font Awesome Free 7.3.1,
|
||||
the set the page's own icons come from: 2,883 icons, solid, regular and brands, searched by name
|
||||
and Font Awesome's search terms ("ai" finds the robot). Without one, a Signal shows its initials.
|
||||
**Auto** asks Jev to choose (#263), and a Signal made without an icon gets Jev's pick by
|
||||
itself, a moment after it is made. Every Signal's tile is in the accent tint the folder has.
|
||||
- **Each Signal's row** says "12 items · 3 unread", as a feed's row counts its own.
|
||||
- **The folder's page** lists the Signals, as an OPML's page lists its feeds, with the + for a new one.
|
||||
- **A Signal's page** is an ordinary item list: All, Unread, Downloaded and Pinned, search and sort.
|
||||
Its header says what it listens for (the sentence, or the words), and that nothing is picked
|
||||
out without a key. Edit changes the name, the words or the sentence; Delete removes the Signal
|
||||
and leaves the items in their feeds.
|
||||
- **Read state is the item's.** Reading an item in a Signal reads it in its feed, and the other way
|
||||
round. The counts follow without a reload (#258).
|
||||
- **Settings, TypeSafe key.** A password field. A key saves on leaving the field, and the bin
|
||||
forgets it. The key is never sent back to the page, which only learns whether one is set.
|
||||
|
||||
## How an item gets into a Signal
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant W as Scan worker
|
||||
participant J as Signals judge<br/>(signals::run)
|
||||
participant DB as Database
|
||||
participant T as TypeSafe<br/>api.typesafe.ai
|
||||
participant P as The page
|
||||
|
||||
W->>DB: new entries stored
|
||||
W->>J: ctx.signals.notify_one()
|
||||
Note over J: also woken by a new Signal,<br/>new keywords, a key saved,<br/>or every 5 minutes
|
||||
J->>DB: unjudged(skip refused keys, 50)
|
||||
DB-->>J: items, each with the Signals<br/>that have not heard of it
|
||||
loop each item
|
||||
J->>T: POST /v1/systemone<br/>one noul question per Signal
|
||||
T-->>J: answers: noul 0..1 per question
|
||||
J->>DB: record_verdicts (hit = noul >= 0.5)
|
||||
end
|
||||
J-->>P: signal_hits event (SSE), once a pass per person
|
||||
P->>DB: GET /api/signals (counts),<br/>and the open Signal's list
|
||||
```
|
||||
|
||||
The judge is a task of its own beside DeadAir's (`crates/ipx/src/signals.rs`), not a job on the
|
||||
scan worker, so a slow TypeSafe never holds up a scan.
|
||||
|
||||
### Which items are asked about
|
||||
|
||||
`Db::unjudged` finds, newest first, up to 50 items that are:
|
||||
|
||||
- in a feed the Signal's owner subscribes to,
|
||||
- published (or, with no date, first seen) in the last 30 days (`SIGNAL_DAYS`),
|
||||
- not hidden from them by their block lists,
|
||||
- not yet in `signal_verdicts` for that Signal,
|
||||
- for someone with a key that TypeSafe has not refused since the daemon started.
|
||||
|
||||
So a new Signal looks back a month, and after that sees each new item. A show subscribed to today
|
||||
has its back catalogue skipped, apart from its last month.
|
||||
|
||||
Rows for one item and one person are grouped, so an item is one call whatever the number of
|
||||
Signals. A full batch means there may be more, and the next pass starts at once. Otherwise the
|
||||
judge sleeps until woken or for 5 minutes.
|
||||
|
||||
### Every answer is kept
|
||||
|
||||
`signal_verdicts` records each answer, fitting or not. An item with no row has not been asked yet;
|
||||
an item with a row is never asked again. So the cost is one call per item per person, once.
|
||||
Changing a Signal's name, words or sentence deletes its verdicts, and everything in the window is
|
||||
asked again. The first start with sentences (#259) deleted every verdict, since they answered the
|
||||
old question.
|
||||
|
||||
## TypeSafe
|
||||
|
||||
Jev is TypeSafe's System One model, already used to pick a feed's Directory category
|
||||
(`feed::classify`). The address is `feed::JEV_URL`, `https://api.typesafe.ai/v1/systemone`.
|
||||
Signals use the **noul** question type, which answers how likely a statement is true, from 0 to 1.
|
||||
Questions in one call are judged independently and in parallel.
|
||||
|
||||
### A request
|
||||
|
||||
Sent with `Authorization: Bearer <the person's key>`, for an item and two of their Signals:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "jev-latest",
|
||||
"state": "Feed: Pit Lane\nTitle: Monaco qualifying\n\nQualifying report from the Monaco Grand Prix.",
|
||||
"questions": {
|
||||
"signal_3": {
|
||||
"type": "noul",
|
||||
"instructions": "The reader follows a subject they call \"F1\", described by these words: formula 1, f1. Is this item about that subject?",
|
||||
"criteria": {
|
||||
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
|
||||
"false": "It is about something else, or touches the subject only in passing."
|
||||
}
|
||||
},
|
||||
"signal_4": {
|
||||
"type": "noul",
|
||||
"instructions": "The reader follows a subject they call \"Food\", described by these words: baking. In their own words: Bread and cakes, not restaurants. Is this item about that subject?",
|
||||
"criteria": {
|
||||
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
|
||||
"false": "It is about something else, or touches the subject only in passing."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `state` is text: the feed's title, the item's title, and its show notes with the HTML taken out,
|
||||
cut to 2,000 characters (`TEXT_CHARS`).
|
||||
- Each question is keyed `signal_<id>`, and its answer comes back under the same key.
|
||||
- `criteria` is an object saying what true and false mean. A sentence there is refused with a 422
|
||||
(#257).
|
||||
- **One subject, not any word.** The first question was "Is this item about any of these: ai,
|
||||
development, programming?", so one broad word was enough: AI Development took Sega pulling a game
|
||||
remaster (0.90), a Surface Laptop review (0.95) and a C++ tutorial (0.99), 54 items in all. Asked
|
||||
about the subject, with the sentence "AI tools for writing software: coding agents, AI dev tools,
|
||||
vibe coding, AI in code review.", those scored 0.06, 0.16 and 0.02, and 5 of the 54 stayed in,
|
||||
AI code review in the Linux kernel highest at 0.94 (#259).
|
||||
|
||||
### Its answer
|
||||
|
||||
What TypeSafe answered to a request of this shape, on 2026-10-16:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "jev-1.13.0",
|
||||
"answers": {
|
||||
"signal_3": { "type": "noul", "noul": 0.96 },
|
||||
"signal_4": { "type": "noul", "noul": 0.02 }
|
||||
},
|
||||
"usage": { "input_tokens": 389, "output_tokens": 40 }
|
||||
}
|
||||
```
|
||||
|
||||
0.5 or more is a fit (`THRESHOLD`): the item goes into Signal 3 and not Signal 4. A question
|
||||
missing from the answer is left unjudged and asked again next pass.
|
||||
|
||||
### When TypeSafe says no
|
||||
|
||||
| TypeSafe answers | What the judge does |
|
||||
|---|---|
|
||||
| 401, 402, 403 | The key is refused. That person is passed over until they save a key or the daemon restarts, so a bad key is not sent the whole queue. Logged once: "TypeSafe refused a Signal key". |
|
||||
| 400, 422 | The request itself is wrong, so every item's would be. The pass ends; the items wait for the next one. Logged: "TypeSafe refused a Signals request as malformed", with TypeSafe's reason. |
|
||||
| anything else, or no answer | That item is left unjudged and asked again next pass. Logged: "Jev could not judge an item". |
|
||||
|
||||
The error keeps TypeSafe's reply, cut to 500 characters, for example:
|
||||
|
||||
```
|
||||
TypeSafe answered 422: {"detail":[{"type":"model_attributes_type","loc":["body","questions","3","noul","criteria"],"msg":"Input should be a valid dictionary or object to extract fields from","input":"Yes if the subject is one of them."}]}
|
||||
```
|
||||
|
||||
## The API
|
||||
|
||||
Every route is the signed-in person's own. A Signal of someone else's is not found, as one that
|
||||
does not exist.
|
||||
|
||||
### `GET /api/signals`
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "name": "AI Development", "keywords": ["ai", "development", "programming"],
|
||||
"description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
|
||||
"icon": "solid/laptop-code", "items": 48, "unread": 5 },
|
||||
{ "id": 2, "name": "Apple", "keywords": ["apple", "mac", "iphone"], "description": null, "icon": null, "items": 272, "unread": 0 }
|
||||
]
|
||||
```
|
||||
|
||||
`items` counts the items Jev picked out that are still in a subscribed feed and not hidden, what
|
||||
its list shows; `unread` those of them not read.
|
||||
|
||||
### `POST /api/signals`
|
||||
|
||||
```json
|
||||
{ "name": "Formula 1", "keywords": ["formula 1", "f1"], "description": "Grand prix racing, not road cars." }
|
||||
```
|
||||
|
||||
Answers `{ "id": 3 }` and wakes the judge. A name is 1 to 100 characters. Between 1 and 50 words
|
||||
are allowed, each up to 200 characters, kept once whatever their case. `description` is optional,
|
||||
up to 500 characters. Anything else is a `400` saying so.
|
||||
|
||||
### `PATCH /api/signals/{id}`
|
||||
|
||||
Any of the three: `{ "name": "F1" }`, `{ "keywords": ["f1", "grand prix"] }`,
|
||||
`{ "description": null }` (null or empty clears it), `{ "icon": "solid/robot" }`. A change to the
|
||||
name, words or sentence drops the Signal's verdicts and wakes the judge, since each is part of the
|
||||
question; a new icon does not. The page sends only what changed. An icon is `solid/`, `regular/` or
|
||||
`brands/` and a plain name, since the page puts it into an address: anything else is a `400`. Answers `204`, or `404` for a Signal that is not yours.
|
||||
|
||||
### `DELETE /api/signals/{id}`
|
||||
|
||||
Answers `204`. The Signal and its verdicts go; the items stay in their feeds.
|
||||
|
||||
### `GET /api/signals/{id}/entries`
|
||||
|
||||
The same query as a feed's list, `offset`, `limit`, `filter`, `q`, `sort`, `dir`:
|
||||
|
||||
```
|
||||
GET /api/signals/3/entries?filter=unread&sort=published&dir=desc&limit=50
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 1,
|
||||
"entries": [
|
||||
{
|
||||
"guid": "sig-1", "feed_id": "pit-lane", "title": "Monaco qualifying",
|
||||
"link": "https://pitlane.example/monaco", "published": 1792170000,
|
||||
"description": "<p>Qualifying report…</p>", "read": false, "flagged": false,
|
||||
"image": null, "duration": null, "episode": null, "season": null, "position": 0,
|
||||
"enclosures": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Reading or pinning one is the item's own route, `POST /api/entries/{feed_id}/{guid}/flags`, as
|
||||
anywhere else.
|
||||
|
||||
### `PATCH /api/me`, the key
|
||||
|
||||
```json
|
||||
{ "typesafe_key": "ts_…" }
|
||||
```
|
||||
|
||||
A string saves the key and lets the judge try it again. `null` or an empty string forgets it. A
|
||||
key is up to 500 printable characters. `GET /api/me` says `"typesafe_key_set": true` and nothing
|
||||
more.
|
||||
|
||||
### `POST /api/signals/icon`
|
||||
|
||||
Jev's pick of an icon, on the **server's** `TYPESAFE_KEY`, not the person's: it is the page's own
|
||||
doing, once a Signal. Jev chooses among options and cannot name one, so the page searches Font
|
||||
Awesome's index for each word of the name, words and sentence, the best six each, and sends up to 40:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "AI Development",
|
||||
"keywords": ["ai", "development", "programming"],
|
||||
"description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
|
||||
"candidates": [["solid/robot", "Robot: ai, android, bot, machine"], ["solid/laptop-code", "Laptop Code: computer, cpu, dell, demo"], ["solid/code", "Code: brackets, code, development, html"]]
|
||||
}
|
||||
```
|
||||
|
||||
ipx asks a **choice** question, each candidate a choice and its label and terms what it means:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "jev-latest",
|
||||
"state": "Signal: AI Development\nListens for: ai, development, programming\nIn their words: AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
|
||||
"questions": { "icon": {
|
||||
"type": "choice",
|
||||
"instructions": "Which icon best stands for this subject in a list of the reader's topics?",
|
||||
"criteria": { "solid/robot": "Robot: ai, android, bot, machine", "solid/laptop-code": "Laptop Code: computer, cpu, dell, demo" }
|
||||
} }
|
||||
}
|
||||
```
|
||||
|
||||
TypeSafe answered, on 2026-10-16, for six candidates:
|
||||
|
||||
```json
|
||||
{ "model": "jev-1.13.0", "answers": { "icon": { "type": "choice", "choice": "solid/laptop-code", "confidence": 0.34,
|
||||
"probabilities": { "solid/robot": 0.4, "solid/code": 0.14, "solid/laptop-code": 0.45, "solid/brain": 0.01, "solid/microchip": 0.0, "solid/terminal": 0.0 } } } }
|
||||
```
|
||||
|
||||
The answer is `{ "icon": "solid/laptop-code" }`, only ever one it was offered. Each candidate has to
|
||||
be an icon name the API takes, 1 to 60 of them. `404` when the server has no key, `502` when Jev
|
||||
fails (logged: "Jev could not pick an icon"). The call has a `jev_icon` span.
|
||||
|
||||
### The event
|
||||
|
||||
Once a pass, for each person it found items for, the judge sends this on the event stream
|
||||
(`/api/events`):
|
||||
|
||||
```json
|
||||
{ "ev": "signal_hits", "user": 2, "hits": 3 }
|
||||
```
|
||||
|
||||
A page with Signals fetches `/api/signals` again, and the open Signal's list.
|
||||
|
||||
## Data
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
users ||--o{ signals : owns
|
||||
signals ||--o{ signal_verdicts : "has heard of"
|
||||
entries ||--o{ signal_verdicts : "judged in"
|
||||
users {
|
||||
bigint id
|
||||
text typesafe_key "NULL: no key"
|
||||
}
|
||||
signals {
|
||||
bigint id
|
||||
bigint user_id
|
||||
text name
|
||||
text keywords "JSON array"
|
||||
text description "NULL: none"
|
||||
text icon "solid/robot, NULL: initials"
|
||||
bigint created
|
||||
}
|
||||
signal_verdicts {
|
||||
bigint signal_id "PK"
|
||||
text feed_id "PK"
|
||||
text guid "PK"
|
||||
bool hit
|
||||
}
|
||||
```
|
||||
|
||||
- `signals` and `signal_verdicts` are created from their entities like every table
|
||||
(`crates/ipx/src/entity.rs`). `users.typesafe_key`, `signals.description` and `signals.icon`
|
||||
were added to existing tables, so `create_missing` adds each column when it is missing.
|
||||
- The icons are files of ipx-web's, not the database's: `web/build.mjs` copies Font Awesome Free's
|
||||
SVGs to `/icons/<style>/<name>.svg`, kept a day, and writes `/icons/index.json`, names, labels
|
||||
and search terms, about 90 KB compressed, named by its hash and fetched when the picker first
|
||||
opens. A Signal's tile draws its one file as a CSS mask, so it takes the theme's colour.
|
||||
- A Signal's list is `entries_where` with
|
||||
`EXISTS (SELECT 1 FROM signal_verdicts v WHERE v.signal_id = $n AND v.feed_id = e.feed_id AND v.guid = e.guid AND v.hit)`
|
||||
on top of the subscribed-feeds scope. So filters, search, sort, block lists and read state all
|
||||
work as for All Subscriptions.
|
||||
- A verdict for a Signal deleted while Jev was being asked is not stored. Postgres's foreign key
|
||||
would refuse it, and SQLite would keep it for nothing.
|
||||
|
||||
## Watching it
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Traces | A `signals` span for each pass (`entries`), and a `jev` span for each call (`user`, `signal_count`, `http.response.status_code`, and TypeSafe's reply as `error` when it refuses). |
|
||||
| Log, counted | `ev="signal"`, "an item fits a Signal", for each item with a fit: `user`, `feed`, `new` (how many of their Signals it fits). |
|
||||
| Log, warnings | The three in the table above. |
|
||||
| Events | `signal_hits` on the socket and `/api/events`. |
|
||||
|
||||
## Limits
|
||||
|
||||
- **The key is kept in plain text** in `users.typesafe_key`, because every call needs it whole. It
|
||||
should be encrypted with a server key if the database is ever shared with anything else.
|
||||
- **Refused keys are remembered in memory**, so a restart tries each once again.
|
||||
- **Keywords changed while an item is being judged** can leave that one item's verdict from the old
|
||||
words.
|
||||
- **There is no meter** of how many calls a key has made. It is one call per item in the person's
|
||||
feeds in the window: a person with 1,500 feeds busy enough to publish 3,000 items a month spends
|
||||
about 3,000 calls the first month and then as many as come in.
|
||||
- **Only the last 30 days** are ever judged, by publish date.
|
||||
|
||||
## Testing
|
||||
|
||||
- `cargo test -p ipx`: the request's shape and the threshold (`signals::tests`), and the queue,
|
||||
verdicts, ownership, unread counts and new keywords (`db::tests::a_signal_shows_what_jev_said_fits_and_only_to_its_owner`),
|
||||
on SQLite and Postgres both.
|
||||
- `node --test tests/page-unit.js`: a Signal's place id and its list's address.
|
||||
- `npx playwright test -g Signal`: makes a Signal from the folder's +, and checks that the matching
|
||||
item shows and that reading it reads it in its feed and clears the badge. TypeSafe is stood in for
|
||||
by the fixture server's `/jev` (`IPX_TYPESAFE_URL`), which refuses the shape TypeSafe refuses, and
|
||||
`/signal.xml` is dated today, since only the last 30 days are judged.
|
||||
@@ -51,8 +51,12 @@ pid = 0
|
||||
def panel(kind, title, w, h, x, targets, **extra):
|
||||
global pid
|
||||
pid += 1
|
||||
sources = {t.get("datasource", LOKI)["uid"] for t in targets}
|
||||
# A panel with queries on more than one data source must be Mixed, or Grafana runs them all on
|
||||
# the panel's and Downloads, Loki's and Prometheus's, failed (#226).
|
||||
ds = {"type": "datasource", "uid": "-- Mixed --"} if len(sources) > 1 else targets[0].get("datasource", LOKI)
|
||||
p = {"id": pid, "type": kind, "title": title, "gridPos": {"x": x, "y": y, "w": w, "h": h},
|
||||
"datasource": targets[0].get("datasource", LOKI), "targets": targets}
|
||||
"datasource": ds, "targets": targets}
|
||||
p.update(extra)
|
||||
panels.append(p)
|
||||
return p
|
||||
@@ -181,6 +185,15 @@ ts("Page response time", [loki(f'quantile_over_time(0.95, {WEB} | unwrap duratio
|
||||
loki(f'max_over_time({WEB} | unwrap duration [$__interval]) by ()', ref="B", legend="slowest")],
|
||||
16, w=8, unit="s", desc="A page waits on ipx's /api/page-check before it is sent.")
|
||||
y += 8
|
||||
# What the person waits for, from the page itself (#246): a click to the list drawn.
|
||||
LIST = SEL + ' |= "\\"ev\\":\\"list_shown\\"" | json | __error__=""'
|
||||
ts("A click to the list shown", [loki(f'quantile_over_time(0.95, {LIST} | unwrap ms [$__interval]) by ()', legend="95th percentile"),
|
||||
loki(f'quantile_over_time(0.5, {LIST} | unwrap ms [$__interval]) by ()', ref="B", legend="median"),
|
||||
loki(f'max_over_time({LIST} | unwrap ms [$__interval]) by ()', ref="C", legend="slowest")],
|
||||
0, w=12, unit="ms", desc="Measured in the browser, network and drawing included. The API's part is in the click's trace.")
|
||||
table("Slowest lists by view", [loki(f'topk(10, max_over_time({LIST} | unwrap ms [$__range]) by (feed, filter))', instant=True)],
|
||||
12, rename={"feed": "View", "filter": "Filter", "Value": "Slowest (ms)"}, sort=[{"displayName": "Slowest (ms)", "desc": True}])
|
||||
y += 8
|
||||
|
||||
# ---- ipx: the API it answers behind ipx-web
|
||||
row("ipx: API")
|
||||
@@ -246,13 +259,10 @@ row("ipx: process")
|
||||
# Open connections stay open, idle, for ten minutes after a burst, so open at the max is not
|
||||
# pressure; drawn as a line of its own it read as the pool maxing out (#199).
|
||||
ts("Database connections", [prom('max(max_over_time(ipx_db_connections{job="ipx", state="in_use"}[$__interval]))', legend="in use"),
|
||||
prom('sum(ipx_db_connections{job="ipx"})', ref="B", legend="open"),
|
||||
prom('max(ipx_db_connections_max{job="ipx"})', ref="C", legend="most the pool opens")],
|
||||
0, w=8, desc="In use reaching the most the pool opens means requests and scans are waiting for a connection. "
|
||||
"Open connections stay open, idle, for ten minutes after a burst.")["fieldConfig"]["overrides"] = [
|
||||
{"matcher": {"id": "byName", "options": "open"},
|
||||
"properties": [{"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [4, 4]}},
|
||||
{"id": "custom.fillOpacity", "value": 0}, {"id": "color", "value": {"mode": "fixed", "fixedColor": "#8e8e8e"}}]}]
|
||||
prom('min(min_over_time(ipx_db_connections{job="ipx", state="idle"}[$__interval]))', ref="B", legend="ready"),
|
||||
prom('max(ipx_db_connections_max{job="ipx"})', ref="C", legend="the pool")],
|
||||
0, w=8, desc="The daemon keeps the whole pool open (#241), so in use and ready add up to the pool. In use reaching "
|
||||
"the pool, ready at 0, means requests and scans are waiting for a connection (#251).")
|
||||
ts("Memory", [prom('ipx_process_resident_bytes{job="ipx"}', legend="resident")], 8, w=8, unit="bytes")
|
||||
ts("Open files and sockets", [prom('ipx_process_open_fds{job="ipx"}', legend="open")], 16, w=8)
|
||||
y += 8
|
||||
@@ -275,7 +285,7 @@ row("ipx-directory")
|
||||
stat("Snapshots", loki(f'sum(count_over_time({DIR} | message="snapshot" [$__range])) or vector(0)', instant=True), 0,
|
||||
desc="ipx sends one when what it shares changes, and every half hour (#211).")
|
||||
stat("Feed checks", loki(f'sum(sum_over_time({DIR} | message="feeds checked" | unwrap checked [$__range])) or vector(0)', instant=True), 4,
|
||||
desc="Each feed every six hours, 25 at a time.")
|
||||
desc="Each feed every twelve hours, five at a time.")
|
||||
stat("Failed feed checks", loki(f'sum(count_over_time({DIR} | message="feed check failed" [$__range])) or vector(0)', instant=True), 8,
|
||||
thresholds=GOOD_BAD, desc="Mostly the publishers': a feed gone or moved.")
|
||||
stat("Requests", loki(f'sum(count_over_time({DIR_HTTP} [$__range])) or vector(0)', instant=True), 12)
|
||||
@@ -295,6 +305,13 @@ table("Failing feeds in the directory", [loki(f'sum by (feed, error) (count_over
|
||||
table("Snapshots by server", [loki(f'sum by (instance) (count_over_time({DIR} | message="snapshot" [$__range]))', instant=True)],
|
||||
12, rename={"instance": "Token", "Value": "Snapshots"}, sort=[{"displayName": "Snapshots", "desc": True}])
|
||||
y += 8
|
||||
# The directory's own /metrics, on a port apart from the tunnel's (#224).
|
||||
ts("Directory memory", [prom('directory_process_resident_bytes{job="ipx-directory"}', legend="resident")], 0, unit="bytes",
|
||||
desc="mimalloc gives back what is freed, so this follows what is in use: a rise that never comes down is a leak.")
|
||||
ts("Feed bytes fetched", [prom('rate(ipx_feed_bytes_total{job="ipx"}[$__rate_interval])', legend="ipx"),
|
||||
prom('rate(directory_feed_bytes_total{job="ipx-directory"}[$__rate_interval])', ref="B", legend="directory")],
|
||||
12, unit="Bps", desc="Feeds are read whole, so this is what scans and checks allocate; set it beside memory.")
|
||||
y += 8
|
||||
|
||||
# ---- Traces: all three send them to Tempo (#213)
|
||||
row("Traces")
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "ipx-web",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ipx-web",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "7.3.1",
|
||||
"@swc/core": "^1.16.2",
|
||||
"@swc/html": "^1.16.2",
|
||||
"hls.js": "1.6.19"
|
||||
@@ -15,6 +18,15 @@
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-free": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz",
|
||||
"integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==",
|
||||
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ipx-web",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"description": "Builds the ipx web pages from web/src (web/build.mjs, run by build.rs) and tests them. The Rust tests cover the server.",
|
||||
"scripts": {
|
||||
@@ -16,6 +16,7 @@
|
||||
"typescript": "^7.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "7.3.1",
|
||||
"@swc/core": "^1.16.2",
|
||||
"@swc/html": "^1.16.2",
|
||||
"hls.js": "1.6.19"
|
||||
|
||||
@@ -60,6 +60,7 @@ module.exports = defineConfig({
|
||||
// production's Tempo and never opens production's database.
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: '',
|
||||
IPX_DATABASE_URL: '',
|
||||
IPX_TYPESAFE_URL: 'http://127.0.0.1:8792/jev',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
655
site/api.mjs
Normal file
655
site/api.mjs
Normal 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 <token></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', '']] },
|
||||
],
|
||||
};
|
||||
@@ -10,3 +10,5 @@ cp "$here/index.html" "$here/site.css" "$here/wave.svg" "$here/gitea.svg" "$here
|
||||
cp "$here/../web/InterVariable.woff2" "$here/dist/fonts/inter.woff2"
|
||||
cp "$here/../web/logo.svg" "$here/../web/favicon.png" "$here/dist/"
|
||||
cp -r "$here/shots" "$here/dist/"
|
||||
# The docs: the guides and the API references, generated (#223).
|
||||
node "$here/gen.mjs"
|
||||
|
||||
24
site/docs.mjs
Normal file
24
site/docs.mjs
Normal 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
212
site/gen.mjs
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
/// 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)}`,
|
||||
` -> ${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
22
site/guides/cli.html
Normal 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 <url></code>, <code>ipx rm <id></code></td><td>Add a podcast, or take it out</td></tr>
|
||||
<tr><td><code>ipx import <file></code>, <code>ipx export <file></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>
|
||||
32
site/guides/directory.html
Normal file
32
site/guides/directory.html
Normal 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 twelve hours.</p>
|
||||
67
site/guides/install.html
Normal file
67
site/guides/install.html
Normal 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://<your server>: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>
|
||||
@@ -14,6 +14,7 @@
|
||||
<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/">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>
|
||||
|
||||
@@ -90,6 +90,84 @@ 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}
|
||||
|
||||
|
||||
/* Docs (#223): a sidebar and a column, as docs.rs lays out a crate. The sidebar is the dial's
|
||||
sky; an endpoint's name is the scale's blue, a type's green, and each method has its own
|
||||
colour, every one AA on the page. Code to read is dark, as on the home page; a declaration is
|
||||
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}
|
||||
.doc h2{margin:2.5rem 0 .75rem;padding-bottom:.35rem;border-bottom:1px solid var(--rule);font-size:1.35rem;letter-spacing:-.01em}
|
||||
.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 :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 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}
|
||||
.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{color:var(--navy);font-weight:700}
|
||||
.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}
|
||||
@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 p{margin:0}
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ export const options = {
|
||||
'http_req_duration{name:all-entries}': p95(50, 100),
|
||||
'http_req_duration{name:feed-entries}': p95(50, 100),
|
||||
'http_req_duration{name:search}': p95(60, 100),
|
||||
'http_req_duration{name:directory}': p95(250, 200),
|
||||
'http_req_duration{name:listed}': p95(300, 200),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -44,11 +42,7 @@ export default function () {
|
||||
const found = http.get(`${BASE}/api/entries?q=weather&limit=50`, as(u, 'search'));
|
||||
check(found, { 'a search of everything you read': r => r.status === 200 && r.json().total === 30 * ITEMS });
|
||||
|
||||
const dir = http.get(`${BASE}/api/directory`, as(u, 'directory'));
|
||||
check(dir, { 'the whole Directory': r => r.status === 200 && r.json().length === FEEDS });
|
||||
|
||||
const listed = http.get(`${BASE}/api/directory/${feedId(Math.floor(Math.random() * FEEDS))}`, as(u, 'listed'));
|
||||
check(listed, { "a feed's page in the Directory": r => r.status === 200 && r.json().items.length === ITEMS });
|
||||
// Not the Directory: since #204 it is ipx-directory's, which this harness does not start (#245).
|
||||
|
||||
sleep(1 + Math.random() * 2);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
// It builds a release binary (debug Argon2 alone takes a second a sign-in, which would measure
|
||||
// the build, not ipx), serves generated feeds from this process, starts a daemon on its own
|
||||
// config and data under /tmp/ipx-load, wiped first, seeds listeners, then runs each k6 script.
|
||||
// While each runs, `ipx status` -- the Docker healthcheck -- is run every second, and the run
|
||||
// fails if any answer takes as long as the healthcheck's 5s timeout.
|
||||
// While each runs, `ipx status` is run every second, and the run fails if any answer takes 5s.
|
||||
//
|
||||
// The daemon is on SQLite unless --postgres, which puts it in IPX_TEST_DATABASE_URL, the
|
||||
// database the Rust tests use on Postgres too (ipodderx_test on the server production's is on, in
|
||||
@@ -190,7 +189,8 @@ async function seed() {
|
||||
}
|
||||
// The media test's listener takes the feeds with files. They may be downloaded already: the
|
||||
// first start subscribes the admin to the whole catalogue, so the first scan fetched them.
|
||||
for (let n = 0; n < MEDIA; n++) await api('listener', `/api/popular/${feedId(n)}`, { method: 'POST' });
|
||||
// By OPML as the others: /api/popular asks the directory, which this harness does not start (#245).
|
||||
await api('listener', '/api/opml', { method: 'POST', body: JSON.stringify({ xml: opml(Array.from({ length: MEDIA }, (_, n) => n)) }) });
|
||||
for (let t = Date.now(); ; await new Promise(r => setTimeout(r, 500))) {
|
||||
const got = await api('listener', '/api/entries?filter=downloaded&limit=50');
|
||||
if (got.entries.flatMap(e => e.enclosures).filter(x => x.path).length >= MEDIA) break;
|
||||
|
||||
@@ -68,6 +68,18 @@ for (const [name, fn] of drive) {
|
||||
}
|
||||
}
|
||||
|
||||
// A trace is one tap's (#219): the same id while requests keep coming, a new one after a quiet
|
||||
// minute, so a refresh hours later is not filed under the last tap.
|
||||
if (PAGE === 'index.html') {
|
||||
const id = () => ctx.traceparent().split('-')[1];
|
||||
vm.runInContext('Date.now = (n => () => n)(1e12)', ctx);
|
||||
const first = id();
|
||||
vm.runInContext('Date.now = (n => () => n)(1e12 + 30_000)', ctx);
|
||||
if (id() !== first) { console.error('FAIL: a trace ended 30 s into a tap'); process.exit(1); }
|
||||
vm.runInContext('Date.now = (n => () => n)(1e12 + 120_000)', ctx);
|
||||
if (id() === first) { console.error('FAIL: a trace outlived a quiet minute'); process.exit(1); }
|
||||
}
|
||||
|
||||
// theme.ts keeps the Settings theme controls in step when Settings is open, and looks before it
|
||||
// touches them. The admin page has no Settings, so those are the ones it may ask for and not find.
|
||||
const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield']);
|
||||
|
||||
201
tests/page-unit.js
Normal file
201
tests/page-unit.js
Normal file
@@ -0,0 +1,201 @@
|
||||
// Unit tests for the page's own functions (#234): the script as shipped, loaded into the stub DOM
|
||||
// page-smoke.js uses, and its helpers called directly. The browser suite tests what people do;
|
||||
// this tests the small pieces it is made of, in a second, with nothing running.
|
||||
//
|
||||
// node --test tests/page-unit.js
|
||||
//
|
||||
// The files in web/src are one script, not modules. Its top-level `function`s are properties of
|
||||
// the context; its top-level `const`s are not, but a script run in the same context sees them, as
|
||||
// a second <script> on a page would, so `run` evaluates an expression there.
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { makeContext, vm } = require('./dom-stub.js');
|
||||
const { buildPage } = require('../web/build.mjs');
|
||||
|
||||
const { html, js } = buildPage('index.html');
|
||||
const { ctx } = makeContext(html, js);
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(js, ctx, { filename: 'index.html<script>', timeout: 5000 });
|
||||
const run = expr => vm.runInContext(expr, ctx);
|
||||
// Time as the page sees it, for the helpers that count from now.
|
||||
const NOW = 1_800_000_000;
|
||||
run(`Date.now = () => ${NOW * 1000}`);
|
||||
|
||||
test('esc makes text safe to put in markup and attributes', () => {
|
||||
assert.equal(run(`esc('<a href="x">Tom & Jerry\\'s</a>')`), '<a href="x">Tom & Jerry's</a>');
|
||||
assert.equal(run('esc(null)'), '');
|
||||
assert.equal(run('esc(undefined)'), '');
|
||||
});
|
||||
|
||||
test('clock reads seconds as a player does', () => {
|
||||
assert.equal(run('clock(0)'), '0:00');
|
||||
assert.equal(run('clock(65)'), '1:05');
|
||||
assert.equal(run('clock(3661)'), '1:01:01');
|
||||
assert.equal(run('clock(59.9)'), '0:59');
|
||||
assert.equal(run('clock(-5)'), '0:00');
|
||||
assert.equal(run('clock(undefined)'), '0:00');
|
||||
});
|
||||
|
||||
test('mb sizes a file in the unit that reads best', () => {
|
||||
assert.equal(run('mb(0)'), '');
|
||||
// An article's small image is not "0 KB" or "0 MB".
|
||||
assert.equal(run('mb(100)'), '1 KB');
|
||||
assert.equal(run('mb(2048)'), '2 KB');
|
||||
assert.equal(run('mb(5 * 1048576)'), '5 MB');
|
||||
assert.equal(run('mb(1.5 * 1073741824)'), '1.5 GB');
|
||||
});
|
||||
|
||||
test('ago says how long since, and never for nothing', () => {
|
||||
assert.equal(run('ago(0)'), 'never');
|
||||
assert.equal(run(`ago(${NOW - 10})`), '1m ago');
|
||||
assert.equal(run(`ago(${NOW - 45 * 60})`), '45m ago');
|
||||
assert.equal(run(`ago(${NOW - 5 * 3600})`), '5h ago');
|
||||
assert.equal(run(`ago(${NOW - 3 * 86400})`), '3d ago');
|
||||
// Past a month, a date: whatever the locale, it names the year.
|
||||
assert.match(run(`ago(${NOW - 90 * 86400})`), /2026/);
|
||||
});
|
||||
|
||||
test('due says how long until a feed is checked', () => {
|
||||
assert.equal(run(`due(${NOW - 1})`), 'due now');
|
||||
assert.equal(run(`due(${NOW + 10})`), 'in 1m');
|
||||
assert.equal(run(`due(${NOW + 90 * 60})`), 'in 2h');
|
||||
assert.equal(run(`due(${NOW + 3 * 86400})`), 'in 3d');
|
||||
});
|
||||
|
||||
test('DeadAir savings read as time', () => {
|
||||
assert.equal(run('spanText(40)'), '40 s');
|
||||
assert.equal(run('spanText(12 * 60 + 5)'), '12 min');
|
||||
assert.equal(run('spanText(3 * 3600 + 12 * 60)'), '3 h 12 min');
|
||||
assert.equal(run('shorter(45)'), '45 s shorter');
|
||||
assert.equal(run('shorter(150)'), '3 min shorter');
|
||||
});
|
||||
|
||||
test('initials and plural', () => {
|
||||
assert.equal(run(`initials('The Verge')`), 'TV');
|
||||
assert.equal(run(`initials('accidental tech podcast')`), 'AT');
|
||||
assert.equal(run(`initials('')`), '?');
|
||||
assert.equal(run(`initials('!!!')`), '?');
|
||||
assert.equal(run(`plural(1, 'feed')`), '1 feed');
|
||||
assert.equal(run(`plural(0, 'feed')`), '0 feeds');
|
||||
assert.equal(run(`plural(2, 'feed')`), '2 feeds');
|
||||
});
|
||||
|
||||
test("kindOf names a file by its type, or else its address's extension", () => {
|
||||
const kind = enc => run(`kindOf(${JSON.stringify(enc)})`);
|
||||
assert.equal(kind({ mime: 'image/png' }), 'image');
|
||||
assert.equal(kind({ mime: 'Video/MP4' }), 'video');
|
||||
assert.equal(kind({ mime: 'audio/mpeg' }), 'audio');
|
||||
assert.equal(kind({ mime: 'application/pdf' }), 'pdf');
|
||||
assert.equal(kind({ mime: 'application/x-bittorrent' }), 'torrent');
|
||||
assert.equal(kind({ url: 'https://x/ep.MP3?token=1' }), 'mp3');
|
||||
assert.equal(kind({ url: 'https://x/download' }), 'file');
|
||||
assert.equal(kind({ url: 'https://x/a.toolongext' }), 'file');
|
||||
assert.equal(kind({}), 'file');
|
||||
});
|
||||
|
||||
test('an item without a title is named by its file', () => {
|
||||
const name = e => run(`entryName(${JSON.stringify(e)})`);
|
||||
assert.deepEqual({ ...name({ title: 'Episode 1' }) }, { text: 'Episode 1', derived: false });
|
||||
assert.deepEqual({ ...name({ title: ' ', enclosures: [{ url: 'https://x/My%20Show%20101.mp3?x=1' }] }) },
|
||||
{ text: 'My Show 101', derived: true });
|
||||
// A name that does not decode is shown as it is, not dropped.
|
||||
assert.deepEqual({ ...name({ enclosures: [{ url: 'https://x/100%.mp3' }] }) }, { text: '100%', derived: true });
|
||||
});
|
||||
|
||||
test('the directory sorts by name as people read, and by subscribers', () => {
|
||||
const sorted = (list, by) => run(`${JSON.stringify(list)}.sort(${by}).map(p => p.title)`);
|
||||
assert.deepEqual([...sorted([{ title: 'ep 10' }, { title: 'Ep 2' }, { title: 'ep 1' }], 'byName')], ['ep 1', 'Ep 2', 'ep 10']);
|
||||
assert.deepEqual([...sorted([{ title: 'B', subscribers: 1 }, { title: 'C', subscribers: 5 }, { title: 'A', subscribers: 1 }], 'bySubs')],
|
||||
['C', 'A', 'B']);
|
||||
});
|
||||
|
||||
test('feeds with something unread go first, in the order they came', () => {
|
||||
const ids = run(`[{id:'a',unread:0},{id:'b',unread:3},{id:'c',unread:0},{id:'d',unread:1}].sort(unreadFirst).map(f => f.id)`);
|
||||
assert.deepEqual([...ids], ['b', 'd', 'a', 'c']);
|
||||
});
|
||||
|
||||
test('words for filters split on commas, blanks dropped', () => {
|
||||
assert.deepEqual([...run(`splitWords(' one, two words ,, three ')`)], ['one', 'two words', 'three']);
|
||||
assert.deepEqual([...run(`splitWords('')`)], []);
|
||||
});
|
||||
|
||||
test("a file plays from DeadAir's playlist when it has one", () => {
|
||||
assert.equal(run(`mediaSrc({ id: 7, deadair: null })`), '/media/7');
|
||||
assert.equal(run(`mediaSrc({ id: 7 })`), '/media/7');
|
||||
assert.equal(run(`mediaSrc({ id: 7, deadair: 0 })`), '/media/7/deadair.m3u8');
|
||||
});
|
||||
|
||||
test('kept lists: the newest few dozen, and a changed feed forgets its own and All Subscriptions\'', () => {
|
||||
run('lists.clear()');
|
||||
for (let i = 0; i < 45; i++) run(`keepList(listKey('f${i}'), {total: 0, entries: []})`);
|
||||
assert.equal(run('lists.size'), 40, 'never more than LISTS_KEPT');
|
||||
assert.equal(run(`lists.has(listKey('f0'))`), false, 'the oldest go first');
|
||||
run(`keepList(listKey('f10'), {total: 1, entries: []})`);
|
||||
run(`keepList(listKey(':all'), {total: 0, entries: []}); keepList(listKey(':all', 'unread'), {total: 0, entries: []})`);
|
||||
run(`forgetLists('f10')`);
|
||||
assert.equal(run(`lists.has(listKey('f10'))`), false);
|
||||
assert.equal(run(`lists.has(listKey(':all')) || lists.has(listKey(':all', 'unread'))`), false, 'All Subscriptions holds its items too');
|
||||
assert.equal(run(`lists.has(listKey('f11'))`), true, 'the rest stay');
|
||||
});
|
||||
|
||||
test('a Patreon feed is told by its address', () => {
|
||||
assert.equal(run(`isPatreon({ url: 'https://www.patreon.com/rss/show?auth=x' })`), true);
|
||||
assert.equal(run(`isPatreon({ url: 'https://example.com/feed' })`), false);
|
||||
assert.equal(run('isPatreon(null)'), false);
|
||||
});
|
||||
|
||||
test('a Signal is a place of its own, found by its id', () => {
|
||||
run(`S.signals = [{id: 7, name: 'F1', keywords: ['f1'], unread: 2}]`);
|
||||
assert.equal(run(`signalId(':signal:7')`), 7);
|
||||
assert.equal(run(`signalId(':all')`), null);
|
||||
assert.equal(run(`signalId('signal-7')`), null, 'a feed id is never a Signal');
|
||||
assert.equal(run(`signalOf(':signal:7').name`), 'F1');
|
||||
assert.equal(run(`signalOf(':signal:8')`), undefined);
|
||||
assert.match(run(`listUrl(':signal:7')`), /^\/api\/signals\/7\/entries\?/);
|
||||
run(`S.signals = []`);
|
||||
});
|
||||
|
||||
test('the icon picker finds icons by the start of their words, the one named it first', () => {
|
||||
const all = JSON.stringify([
|
||||
['solid/caret-down', 'Caret Down', 'arrow'], ['solid/scarf', 'Scarf', 'clothing'], ['solid/taxi', 'Taxi', 'cab car'],
|
||||
['solid/car-side', 'Car Side', 'auto vehicle'], ['solid/car', 'Car', 'auto vehicle'], ['solid/robot', 'Robot', 'ai bot machine'],
|
||||
['brands/apple', 'Apple', 'fruit ios mac'],
|
||||
]);
|
||||
const ids = q => JSON.parse(run(`JSON.stringify(iconMatches(${all}, ${JSON.stringify(q)}, 10).map(i => i[0]))`));
|
||||
// Named car, then car as a word of the name, then as a search term, then caret; never scarf.
|
||||
assert.deepEqual(ids('car'), ['solid/car', 'solid/car-side', 'solid/taxi', 'solid/caret-down']);
|
||||
assert.deepEqual(ids('ai'), ['solid/robot'], 'search terms count');
|
||||
assert.deepEqual(ids('mac fruit'), ['brands/apple'], 'every word');
|
||||
assert.equal(run(`iconMatches(${all}, '', 2).length`), 2, 'nothing typed is the first few');
|
||||
});
|
||||
|
||||
test('editing a Signal sends only what changed, so a new icon asks Jev nothing again', () => {
|
||||
const sg = `{id: 1, name: 'F1', keywords: ['f1'], description: null, icon: null, unread: 0}`;
|
||||
assert.deepEqual(JSON.parse(run(`JSON.stringify(signalChanges(${sg}, {name: 'F1', keywords: ['f1'], description: null, icon: 'solid/car'}))`)),
|
||||
{ icon: 'solid/car' });
|
||||
assert.deepEqual(JSON.parse(run(`JSON.stringify(signalChanges(${sg}, {name: 'F1', keywords: ['f1', 'gp'], description: null, icon: null}))`)),
|
||||
{ keywords: ['f1', 'gp'] });
|
||||
});
|
||||
|
||||
test('a Signal draws its icon only when it is a style and a plain name', () => {
|
||||
assert.match(run(`signalTile({name: 'F1', icon: 'solid/flag-checkered'})`), /url\('\/icons\/solid\/flag-checkered\.svg'\)/);
|
||||
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: "solid/x');background:url(evil"})`), /evil/);
|
||||
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: null})`), /ficon/);
|
||||
});
|
||||
|
||||
test('Jev is offered the icons found for each word of a Signal', () => {
|
||||
const all = JSON.stringify([
|
||||
['solid/robot', 'Robot', 'ai bot machine'], ['solid/code', 'Code', 'programming brackets'],
|
||||
['solid/car', 'Car', 'auto vehicle'], ['solid/the-red-yeti', 'The Red Yeti', ''],
|
||||
]);
|
||||
const ids = JSON.parse(run(`JSON.stringify(iconCandidates(${all}, 'AI Development', ['programming'], 'The tools', 40).map(c => c[0]))`));
|
||||
assert.deepEqual(ids, ['solid/robot', 'solid/code'], 'each word its icons, "the" none');
|
||||
const brands = JSON.stringify([
|
||||
['brands/mandalorian', 'Mandalorian', 'disney plus helmet star wars'], ['brands/apple', 'Apple', 'fruit ios mac'],
|
||||
['solid/fort-awesome', 'Fort', 'castle'], ['solid/apple-whole', 'Apple Whole', 'fruit'],
|
||||
]);
|
||||
const offered = q => JSON.parse(run(`JSON.stringify(iconCandidates(${brands}, ${JSON.stringify(q)}, [], null, 40).map(c => c[0]))`));
|
||||
assert.deepEqual(offered('Disney castle'), ['solid/fort-awesome'], 'a brand only by its own name, never its search terms (#264)');
|
||||
assert.deepEqual(offered('Apple'), ['brands/apple', 'solid/apple-whole']);
|
||||
assert.equal(run(`signalCounts({items: 12, unread: 1})`), '12 items · 1 unread');
|
||||
});
|
||||
@@ -11,7 +11,8 @@ test('the page loads and lists the configured feeds', async ({ page }) => {
|
||||
// Regression: a ReferenceError in the script left the shell rendered and the sidebar
|
||||
// empty, with every handler below the error dead. Server-side checks all passed.
|
||||
// Four top-level feeds in the fixture config; the OPML's children are inside a closed folder.
|
||||
await expect(page.locator('.feed')).toHaveCount(5, { timeout: 15_000 });
|
||||
// The Signals folder is above them, and no feed.
|
||||
await expect(page.locator('.feed:not(.sigs)')).toHaveCount(5, { timeout: 15_000 });
|
||||
await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible();
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
@@ -403,7 +404,8 @@ test('an OPML subscription is a collapsible folder', async ({ page }) => {
|
||||
// Only a folder has a triangle, and it is a button that says whether the folder is open.
|
||||
const chev = page.locator('.feed.group .chev');
|
||||
await expect(chev).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator('.feed:not(.group) .chev')).toHaveCount(0);
|
||||
// The Signals folder is a folder too (#255).
|
||||
await expect(page.locator('.feed:not(.group):not(.sigs) .chev')).toHaveCount(0);
|
||||
await expect(chev).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
// Closed by default: the children are not listed until the folder is opened.
|
||||
@@ -1141,6 +1143,7 @@ test('one action, one icon: the toolbar, the page and every dialog agree', async
|
||||
() => page.locator('#content .acts [data-a="settings"]').click(),
|
||||
() => page.locator('#content .acts [data-a="dl"]').click(),
|
||||
() => page.locator('#content .acts [data-a="rm"]').click(),
|
||||
async () => { await page.locator('#feedlist .sigs').click(); await page.locator('#content .acts [data-a="new"]').click(); },
|
||||
];
|
||||
for (const open of dialogs) {
|
||||
await open();
|
||||
@@ -1668,7 +1671,8 @@ test.describe('an item with no files, on a phone', () => {
|
||||
});
|
||||
|
||||
test('a pinned feed, even one from inside a folder, goes to the top of the list', async ({ page }) => {
|
||||
const rows = page.locator('#feedlist .feed');
|
||||
// The Signals folder is first, under All Subscriptions (#256), and no feed.
|
||||
const rows = page.locator('#feedlist .feed:not(.sigs)');
|
||||
const group = page.locator('.feed.group').first();
|
||||
if ((await group.locator('.chev').getAttribute('aria-expanded')) !== 'true') await group.locator('.chev').click();
|
||||
const child = page.locator('.feed.child').first();
|
||||
@@ -1697,15 +1701,30 @@ test('a pinned feed, even one from inside a folder, goes to the top of the list'
|
||||
test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => {
|
||||
const row = page.locator('#feedlist .feed', { hasText: 'Test Show' });
|
||||
await expect(row).toBeVisible();
|
||||
await page.evaluate(() => setScanning('test-show', true));
|
||||
// A check of a few milliseconds leaves the count where it is, or every count blinked as a scan
|
||||
// went by; the spinner comes once the check has taken a moment (#232).
|
||||
const atOnce = await page.evaluate(() => {
|
||||
setScanning('test-show', true);
|
||||
return document.querySelector('#feedlist .feed[data-id="test-show"]').classList.contains('scanning');
|
||||
});
|
||||
expect(atOnce, 'no spinner the moment a check starts').toBe(false);
|
||||
await expect(row).toHaveClass(/\bscanning\b/);
|
||||
await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
|
||||
await page.evaluate(() => setScanning('test-show', false));
|
||||
await expect(row).not.toHaveClass(/\bscanning\b/);
|
||||
await expect(row.locator('.badge')).toBeVisible();
|
||||
// The button turns from the press to the end of the scan it asked for (#233): a scheduled
|
||||
// scan's end, or the end of a scan of one other feed, leaves it turning.
|
||||
const spins = () => document.body.classList.contains('scan-any');
|
||||
expect(await page.evaluate(spins => { spinStart(null); spinEnd({ only: null, forced: false });
|
||||
const a = document.body.classList.contains('scan-any'); spinEnd({ only: 'test-show', forced: true });
|
||||
return a && document.body.classList.contains('scan-any'); })).toBe(true);
|
||||
await page.evaluate(() => spinEnd({ only: null, forced: true }));
|
||||
expect(await page.evaluate(spins)).toBe(false);
|
||||
// Checking every feed says so on the rows, not in a toast (issue #37).
|
||||
await page.locator('#scanAll').click();
|
||||
await page.waitForTimeout(1500);
|
||||
expect(await page.evaluate(() => { scanAll(); return document.body.classList.contains('scan-any'); }),
|
||||
'turning the moment it is pressed').toBe(true);
|
||||
await expect(page.locator('body')).not.toHaveClass(/\bscan-any\b/, { timeout: 15_000 });
|
||||
await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -1766,20 +1785,28 @@ test('Share hands the feed, the item and the file to the share sheet', async ({
|
||||
]);
|
||||
});
|
||||
|
||||
test('a refresh button turns while what it checks is being checked', async ({ page }) => {
|
||||
test('a refresh button turns from the press to the end of the scan it asked for', async ({ page }) => {
|
||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||
const spin = sel => page.locator(sel).evaluate(el => getComputedStyle(el).animationName);
|
||||
const btn = '.fhead [data-a=scan] .i';
|
||||
await expect(page.locator(btn)).toBeVisible();
|
||||
expect(await spin(btn)).toBe('none');
|
||||
await page.evaluate(() => setScanning('test-show', true));
|
||||
// Pressed: this feed's button turns, the toolbar's does not (#78, #233).
|
||||
await page.evaluate(() => spinStart('test-show'));
|
||||
expect(await spin(btn)).toBe('ipxspin');
|
||||
expect(await spin('#scanAll .i')).toBe('ipxspin');
|
||||
// Another feed being checked turns the toolbar's, not this page's.
|
||||
await page.evaluate(() => { setScanning('test-show', false); setScanning('multi-show', true); });
|
||||
expect(await spin('#scanAll .i')).toBe('none');
|
||||
// Feeds being checked one by one no longer start or stop it (#231).
|
||||
await page.evaluate(() => { setScanning('test-show', true); setScanning('test-show', false); });
|
||||
expect(await spin(btn)).toBe('ipxspin');
|
||||
// Another feed's scan ending, or a scheduled one's, leaves it turning; its own stops it.
|
||||
await page.evaluate(() => { spinEnd({ only: 'multi-show', forced: true }); spinEnd({ only: null, forced: false }); });
|
||||
expect(await spin(btn)).toBe('ipxspin');
|
||||
await page.evaluate(() => spinEnd({ only: 'test-show', forced: true }));
|
||||
expect(await spin(btn)).toBe('none');
|
||||
// The toolbar's turns for a scan of every feed, until that scan ends.
|
||||
await page.evaluate(() => spinStart(null));
|
||||
expect(await spin('#scanAll .i')).toBe('ipxspin');
|
||||
await page.evaluate(() => setScanning('multi-show', false));
|
||||
await page.evaluate(() => spinEnd({ only: null, forced: true }));
|
||||
expect(await spin('#scanAll .i')).toBe('none');
|
||||
});
|
||||
|
||||
@@ -1913,6 +1940,36 @@ test('DeadAir is off until turned on for a show, then plays it shortened, with y
|
||||
await page.locator('#ssave').click();
|
||||
await expect.poll(async () => (await entry()).enclosures[0].deadair).toBeLessThan(usual);
|
||||
|
||||
// Someone else on the same show at the usual pause, at the same address at the same time: one
|
||||
// file on disk, a playlist each, and neither kept by a cache to be handed to the other.
|
||||
{
|
||||
const { execFileSync } = require('child_process');
|
||||
const setup = require('./global-setup');
|
||||
const env = { ...process.env, IPX_CONFIG: `${setup.root}/config/config.toml`, IPX_DATA_DIR: `${setup.root}/data` };
|
||||
try { execFileSync('./target/debug/ipx', ['user', 'add', 'quinn'], { input: 'quinnpassword', env }); }
|
||||
catch (e) { if (!String(e.stderr || e.stdout).includes('already exists')) throw e; }
|
||||
const ctx = await page.context().browser().newContext();
|
||||
const quinn = await ctx.newPage();
|
||||
await quinn.goto('/login');
|
||||
await quinn.locator('#name').fill('quinn');
|
||||
await quinn.locator('#pw').fill('quinnpassword');
|
||||
await quinn.locator('button[type=submit]').click();
|
||||
await expect(quinn.locator('#feedlist')).toBeVisible();
|
||||
await quinn.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/quiet.xml' } });
|
||||
await quinn.request.patch(`/api/feeds/${encodeURIComponent(id)}`, { data: { deadair: true } });
|
||||
const enc = (await entry()).enclosures[0].id;
|
||||
const fetchList = async p => { const r = await p.request.get(`/media/${enc}/deadair.m3u8`);
|
||||
return { status: r.status(), cache: r.headers()['cache-control'], body: await r.text() }; };
|
||||
const [mine, theirs] = await Promise.all([fetchList(page), fetchList(quinn)]);
|
||||
const length = l => l.body.match(/#EXTINF:([\d.]+)/g).reduce((s, x) => s + Number(x.slice(8)), 0);
|
||||
expect([mine.status, theirs.status]).toEqual([200, 200]);
|
||||
expect([mine.cache, theirs.cache]).toEqual(['no-store', 'no-store']);
|
||||
expect(mine.body).not.toBe(theirs.body);
|
||||
expect(length(theirs), 'the usual pause cuts more than the longer one').toBeLessThan(length(mine));
|
||||
await quinn.request.delete(`/api/feeds/${encodeURIComponent(id)}`);
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// Saved without its dead air too (#186): smaller than the original, named to say so.
|
||||
const saved = await page.evaluate(async id => {
|
||||
const [d, o, p] = await Promise.all([fetch(`/media/${id}/deadair`), fetch(`/media/${id}`), fetch(`/media/${id}/deadair.mp3`, { headers: { Range: 'bytes=0-99' } })]);
|
||||
@@ -1962,3 +2019,111 @@ test('a downloaded file\'s buttons sit on one row in the files pane', async ({ p
|
||||
const tops = await box.locator('.btn').evaluateAll(bs => bs.map(b => Math.round(b.getBoundingClientRect().top)));
|
||||
expect(new Set(tops).size, 'play, save, share and delete wrapped onto two rows').toBe(1);
|
||||
});
|
||||
|
||||
test('a list seen before shows at once, and one not yet seen shows a spinner while it loads', async ({ page }) => {
|
||||
// #247. Every list's answer held back a second and a half, as a slow connection would.
|
||||
await page.route(/\/api\/(entries|feeds\/[^/]+\/entries)\?/, async route => {
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
await route.continue();
|
||||
});
|
||||
await page.evaluate(() => { lists.clear(); fetching.clear(); });
|
||||
await page.locator('.place[data-id=":all"]').click();
|
||||
await expect(page.locator('#eps .listload'), 'waited for, and said so').toBeVisible();
|
||||
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 10_000 });
|
||||
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
||||
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 10_000 });
|
||||
// Back to All Subscriptions: drawn from what was kept, before the slow answer could come.
|
||||
await page.locator('.place[data-id=":all"]').click();
|
||||
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 500 });
|
||||
await expect(page.locator('#eps .listload')).toHaveCount(0);
|
||||
await page.unroute(/\/api\/(entries|feeds\/[^/]+\/entries)\?/);
|
||||
});
|
||||
|
||||
test('Currently Listening and the Directory show at once once seen, with a spinner the first time', async ({ page }) => {
|
||||
// #248. Both answers held back a second and a half.
|
||||
const slow = /\/api\/(directory$|entries\?filter=in_progress)/;
|
||||
await page.route(slow, async route => { await new Promise(r => setTimeout(r, 1500)); await route.continue(); });
|
||||
// What the page fetched ahead when it loaded is let finish and then forgotten, and no more is
|
||||
// fetched ahead, or the first visit would find the Directory already here, as it is meant to.
|
||||
await page.waitForFunction(() => pageFetching.size === 0 && fetching.size === 0);
|
||||
await page.evaluate(() => { window.preloadLists = () => {}; pages.clear(); });
|
||||
for (const [id, drawn] of [[':listening', '#listening .childrow, #listening .hint'], [':directory', '#directory > *:not(.listload)']]) {
|
||||
await page.locator(`.place[data-id="${id}"]`).click();
|
||||
await expect(page.locator('.listload'), `${id}: waited for, and said so`).toBeVisible();
|
||||
await expect(page.locator(drawn).first()).toBeVisible({ timeout: 10_000 });
|
||||
await page.locator('.place[data-id=":all"]').click();
|
||||
await page.locator(`.place[data-id="${id}"]`).click();
|
||||
await expect(page.locator(drawn).first(), `${id}: drawn from what was kept`).toBeVisible({ timeout: 500 });
|
||||
await expect(page.locator('.listload')).toHaveCount(0);
|
||||
}
|
||||
await page.unroute(slow);
|
||||
});
|
||||
|
||||
test('the page comes compressed, and the compressed copies cannot be asked for by name', async ({ page }) => {
|
||||
// #253: brotli made at build, served in place of the file.
|
||||
const js = await page.request.get('/app.js', { headers: { 'Accept-Encoding': 'br, gzip' } });
|
||||
expect(js.status()).toBe(200);
|
||||
expect(js.headers()['content-encoding']).toBe('br');
|
||||
expect(js.headers()['content-type']).toContain('javascript');
|
||||
const gz = await page.request.get('/app.css', { headers: { 'Accept-Encoding': 'gzip' } });
|
||||
expect(gz.headers()['content-encoding']).toBe('gzip');
|
||||
// The admin page's script, under its compressed name, would skip the check that keeps it to admins.
|
||||
for (const p of ['/admin.js.br', '/admin.html.gz', '/app.js.br']) {
|
||||
expect((await page.request.get(p)).status(), p).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
test('a Signal holds the items Jev says fit its keywords, read where they are read', async ({ page }) => {
|
||||
// #254. Jev is the fixture server's stand-in; the show is added here and removed at the end.
|
||||
expect((await page.request.patch('/api/me', { data: { typesafe_key: 'test-key' } })).status()).toBe(204);
|
||||
const { id: feed } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/signal.xml' } })).json();
|
||||
await page.reload();
|
||||
|
||||
// The + on the folder's page (#265); making one opens the folder to show it.
|
||||
await page.locator('#feedlist .sigs').click();
|
||||
await page.locator('#content .acts [data-a="new"]').click();
|
||||
await page.locator('#sgname').fill('F1');
|
||||
await page.locator('#sgkwin').fill('monaco');
|
||||
await page.keyboard.press('Enter');
|
||||
// An icon of its own, found by searching Font Awesome Free (#261).
|
||||
await page.locator('#sgicon').click();
|
||||
await page.locator('#sgiconq').fill('flag checkered');
|
||||
await page.locator('[data-ic="solid/flag-checkered"]').click();
|
||||
await page.locator('#sgsave').click();
|
||||
const row = page.locator('#feedlist .feed.child', { hasText: 'F1' });
|
||||
await expect(row).toHaveClass(/sel/);
|
||||
await expect(row.locator('.ficon')).toHaveAttribute('style', /\/icons\/solid\/flag-checkered\.svg/);
|
||||
expect((await page.request.get('/icons/solid/flag-checkered.svg')).status()).toBe(200);
|
||||
|
||||
const signals = async () => (await (await page.request.get('/api/signals')).json());
|
||||
const sg = (await signals()).find(s => s.name === 'F1');
|
||||
const shown = async () => (await (await page.request.get(`/api/signals/${sg.id}/entries`)).json()).entries.map(e => e.title);
|
||||
await expect.poll(shown, { timeout: 15_000 }).toEqual(['Monaco qualifying']);
|
||||
|
||||
await page.reload();
|
||||
await row.click();
|
||||
await page.locator('.ep', { hasText: 'Monaco qualifying' }).click();
|
||||
await expect.poll(async () => (await (await page.request.get(`/api/entries/${encodeURIComponent(feed)}/sig-1`)).json()).read,
|
||||
{ timeout: 5_000 }).toBe(true);
|
||||
expect((await signals()).find(s => s.id === sg.id).unread).toBe(0);
|
||||
// And the sidebar says so without a reload: it kept saying 1 (#258).
|
||||
await expect(row.locator('.badge')).toHaveText('0');
|
||||
|
||||
expect((await page.request.get(`/api/signals/${sg.id + 1000}/entries`)).status(), 'no such Signal').toBe(404);
|
||||
|
||||
await page.request.delete(`/api/signals/${sg.id}`);
|
||||
await page.request.delete(`/api/feeds/${encodeURIComponent(feed)}`);
|
||||
await page.request.patch('/api/me', { data: { typesafe_key: null } });
|
||||
});
|
||||
|
||||
test('a Signal with no key to judge it links to where one is got and where it goes', async ({ page }) => {
|
||||
await page.request.patch('/api/me', { data: { typesafe_key: null } });
|
||||
const { id } = await (await page.request.post('/api/signals', { data: { name: 'Keyless', keywords: ['x'] } })).json();
|
||||
await page.reload();
|
||||
await page.evaluate(id => selectFeed(':signal:' + id), id);
|
||||
await expect(page.locator('#content a[href="https://typesafe.ai"]')).toBeVisible();
|
||||
await page.locator('#content [data-keylink]').click();
|
||||
await expect(page.locator('#stskey')).toBeFocused();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.request.delete(`/api/signals/${id}`);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,35 @@ const dir = __dirname;
|
||||
const port = Number(process.env.FIXTURE_PORT || 8792);
|
||||
|
||||
http.createServer((req, res) => {
|
||||
// Jev, for Signals (#254): sure of an item whose text has a question's keyword in it. Refuses
|
||||
// a request in the shape TypeSafe refuses, as it does (#257).
|
||||
if (req.method === 'POST' && req.url === '/jev') {
|
||||
let body = '';
|
||||
req.on('data', c => { body += c; });
|
||||
req.on('end', () => {
|
||||
const { state, questions } = JSON.parse(body);
|
||||
if (Object.values(questions).some(q => typeof q.criteria !== 'object')) {
|
||||
res.writeHead(422, { 'content-type': 'application/json' }).end('{"detail":"criteria should be an object"}');
|
||||
return;
|
||||
}
|
||||
const title = String(state).toLowerCase();
|
||||
const answers = {};
|
||||
for (const [id, q] of Object.entries(questions)) {
|
||||
const words = /described by these words: ([^.]*)\./.exec(q.instructions)[1].split(', ');
|
||||
answers[id] = { noul: words.some(w => title.includes(w.toLowerCase())) ? 0.9 : 0.1 };
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ answers }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Dated today, since a Signal looks at the last 30 days only.
|
||||
if (req.url === '/signal.xml') {
|
||||
const now = new Date().toUTCString();
|
||||
const item = (guid, title) => `<item><guid>${guid}</guid><title>${title}</title><pubDate>${now}</pubDate></item>`;
|
||||
res.writeHead(200, { 'content-type': 'application/xml' }).end(`<?xml version="1.0"?><rss version="2.0"><channel>
|
||||
<title>Signal Show</title>${item('sig-1', 'Monaco qualifying')}${item('sig-2', 'Baking bread')}</channel></rss>`);
|
||||
return;
|
||||
}
|
||||
const name = decodeURIComponent(req.url.split('?')[0].replace(/^\//, '')) || 'index';
|
||||
const file = path.join(dir, path.basename(name));
|
||||
fs.readFile(file, (err, body) => {
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
"target": "es2022",
|
||||
"lib": ["es2022", "dom", "dom.iterable"],
|
||||
"noEmit": true,
|
||||
"strict": false,
|
||||
// Strict throughout (#235, #236). $ assumes the element is there: the page check proves the
|
||||
// ones wired at load exist, and a lookup that may find nothing says so with ?.
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["web/src/*.ts"]
|
||||
|
||||
@@ -11,6 +11,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.1.0] - 2026-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Signals pick what matters out of the noise of every feed you subscribe to: New signal takes a name, the words to listen for and, if those are not enough, a sentence saying what you mean, and its page lists the items from your feeds that are about it, read and unread as in their own feeds. Settings takes your TypeSafe key, which Signals need, and a Signal without one links to typesafe.ai to get one and to the key's place in Settings (#254, #266). They are kept in a Signals folder first in the feed list, under All Subscriptions, closed or open as an OPML is, with its own page listing them and a + there for a new one (#255, #256, #265). Each can have an icon of its own, searched for among all of Font Awesome Free, in place of its initials (#261), or one picked for it with Auto, which a Signal made without an icon gets by itself; each Signal's row counts its items and unread as a feed's does (#263).
|
||||
- A list opens at once when you have seen it before or the page fetched it ahead: All Subscriptions and the feeds with something unread are fetched when the page loads, and a feed or filter as the pointer or a finger reaches it, then refreshed behind once shown. A list that has to be waited for shows a spinner instead of nothing (#247). Currently Listening and the Directory's front page are fetched ahead the same way and open at once (#248).
|
||||
|
||||
### Changed
|
||||
|
||||
- The page reports how long each list you open takes to show, from the click to the list drawn, for the dashboard (#246).
|
||||
- The page's files are compressed once when built, brotli at its strongest and gzip, and sent in place of the files: the script 30 KB where it was 37, the stylesheet 11 where it was 14. Traces go to the collector compressed (#253).
|
||||
- The page and the API's answers are sent compressed, zstd or gzip, which the feed list and item lists, over 100 KB each, were not; live updates and audio are not compressed (#239).
|
||||
|
||||
## [1.0.2] - 2026-10-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- The refresh buttons turn from the moment they are pressed until the scan they asked for has finished, smoothly, instead of jerking back upright between one feed and the next (#231, #233).
|
||||
- A feed's unread count stays put while a scan checks it quickly; the spinner takes its place only when a check takes a moment (#232).
|
||||
|
||||
### Security
|
||||
|
||||
- The ipx-web image no longer carries curl, which Caddy's base image shipped and nothing used: 8.22.0 there is affected by CVE-2026-92392 (#237).
|
||||
|
||||
## [1.0.1] - 2026-10-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- A live update or a list refresh that fails because the network is gone (a laptop asleep) is no longer reported as an error, an error page from Cloudflare no longer becomes a request's message, and requests a minute or more after a tap start a trace of their own instead of joining that tap's (#219).
|
||||
|
||||
## [1.0.0] - 2026-10-12
|
||||
|
||||
### Added
|
||||
@@ -32,5 +62,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- On a phone, the Subscribe button on a Directory feed's page is full size again, not squashed to a sliver (#212).
|
||||
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.0...main
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/web-v1.1.0...main
|
||||
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.2...web-v1.1.0
|
||||
[1.0.2]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.1...web-v1.0.2
|
||||
[1.0.1]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.0...web-v1.0.1
|
||||
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/web-v1.0.0
|
||||
|
||||
@@ -57,6 +57,21 @@ http://{$WEB_BIND::8080} {
|
||||
}
|
||||
}
|
||||
|
||||
# Compressed, zstd or gzip as the browser takes them: the feed list and a page of items went
|
||||
# out at over 100 KB each (#239). Not the live updates, which a compressor would hold back
|
||||
# until it had enough to send, nor /media, which is audio and video already compressed.
|
||||
@compress not path /api/events /media/*
|
||||
encode @compress zstd gzip
|
||||
|
||||
# The copies web/build.mjs compressed are served in place of their file, never asked for by
|
||||
# name: /admin.js.br would be the admin page's script without the check below that keeps it to
|
||||
# admins (#253).
|
||||
# A handle of its own: a bare respond runs after the handles, and the last one serves any file.
|
||||
@packed path *.br *.gz
|
||||
handle @packed {
|
||||
respond 404
|
||||
}
|
||||
|
||||
# Before anything else, so no page or API call sees them: only ipx sets these.
|
||||
@forged not remote_ip {$TUNNEL_PEER:255.255.255.255}
|
||||
request_header @forged -Cf-Access-Authenticated-User-Email
|
||||
@@ -91,7 +106,9 @@ http://{$WEB_BIND::8080} {
|
||||
rewrite / /index.html
|
||||
rewrite /admin /admin.html
|
||||
header Cache-Control no-cache
|
||||
file_server
|
||||
file_server {
|
||||
precompressed br gzip
|
||||
}
|
||||
}
|
||||
|
||||
# The sign-in form, except for someone Cloudflare Access vouches for, who is sent to the app.
|
||||
@@ -102,18 +119,22 @@ http://{$WEB_BIND::8080} {
|
||||
}
|
||||
rewrite * /login.html
|
||||
header Cache-Control no-cache
|
||||
file_server
|
||||
file_server {
|
||||
precompressed br gzip
|
||||
}
|
||||
}
|
||||
|
||||
# Scripts and the stylesheet are named by a hash of their contents (web/build.mjs), so a browser
|
||||
# keeps them a year; the icons a day, under names that do not change.
|
||||
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js
|
||||
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js /icons/index.json
|
||||
header @hashed Cache-Control "public, max-age=31536000, immutable"
|
||||
# The same PNG as /favicon.png, for a browser that asks for /favicon.ico on its own.
|
||||
header /favicon.ico Content-Type image/png
|
||||
@icons path *.png *.svg *.ico *.woff2
|
||||
header @icons Cache-Control "public, max-age=86400"
|
||||
handle {
|
||||
file_server
|
||||
file_server {
|
||||
precompressed br gzip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
30
web/app.css
30
web/app.css
@@ -501,9 +501,9 @@ a{color:var(--accent)}
|
||||
}
|
||||
#applogo{display:flex;flex:none}
|
||||
#applogo img{width:28px;height:28px;border-radius:7px}
|
||||
/* The logo in the mode's own variant. Dark unless the page says light, as the palette is: with
|
||||
/* The logo, and the app-icon tiles, in the mode's own variant. Dark unless the page says light, as the palette is: with
|
||||
Auto, data-mode arrives with the script, and until then the page is drawn dark. */
|
||||
:root[data-mode="light"] .logo.ondark,:root:not([data-mode="light"]) .logo.onlight{display:none}
|
||||
:root[data-mode="light"] .ondark,:root:not([data-mode="light"]) .onlight{display:none}
|
||||
/* Classic is the 2004 app, so it wears the 2004 app's icon. */
|
||||
.logo.classic,:root[data-theme="classic"] .logo:not(.classic){display:none}
|
||||
:root[data-theme="classic"] .logo.classic{display:block}
|
||||
@@ -590,6 +590,26 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
||||
.feed.lastpin{margin-bottom:9px}
|
||||
.feed.lastpin::after{content:"";position:absolute;left:8px;right:8px;bottom:-5px;border-bottom:1px solid var(--line)}
|
||||
.feed.child .art{width:28px;height:28px;font-size:11px}
|
||||
/* A Signal's own icon (#261): Font Awesome's file as a mask, so it is drawn in the text's colour
|
||||
and follows the theme as the page's own icons do. */
|
||||
.ficon{
|
||||
display:block;width:50%;height:50%;background:currentColor;
|
||||
-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat;
|
||||
}
|
||||
.iconpick{flex:none;border-radius:9px}
|
||||
.iconpick .art{width:38px;height:38px;font-size:13px}
|
||||
.icongrid{
|
||||
display:grid;grid-template-columns:repeat(auto-fill,minmax(40px,1fr));gap:4px;
|
||||
max-height:220px;overflow-y:auto;margin-top:8px;
|
||||
}
|
||||
.iconcell{height:40px;border-radius:8px;display:grid;place-items:center;color:var(--dim)}
|
||||
.iconcell:hover,.iconcell[aria-selected="true"]{background:var(--raise);color:var(--fg)}
|
||||
.iconcell .ficon{width:20px;height:20px}
|
||||
/* Auto in the icon picker, while an icon is picked: the scan spinner, beside the word. */
|
||||
.dlink.busy::after{
|
||||
content:"";display:inline-block;width:10px;height:10px;margin-left:6px;vertical-align:-1px;border-radius:50%;
|
||||
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite;
|
||||
}
|
||||
/* Only a folder has a triangle, hung in the margin so every feed's art lines up with the places
|
||||
above it. The button is the row's full height and 24 px wide: a near miss used to open the
|
||||
folder's page, and 16 px left the triangle cramped against the art. */
|
||||
@@ -756,6 +776,8 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
||||
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
|
||||
display:grid;place-items:center;color:var(--faint);font-weight:600;overflow:hidden;
|
||||
}
|
||||
.art>.tile{width:100%;height:100%;display:block}
|
||||
#pdead .tile{width:26px;height:26px;border-radius:6px}
|
||||
/* Initials, tinted by a hash of the name so two "TS" in a row can be told apart. Mixed into the
|
||||
page ground, not --raise: that is also the selected row, and a selected tile vanished into it. */
|
||||
.art.ini{background:color-mix(in srgb,var(--tint) 24%,var(--bg));color:var(--tint)}
|
||||
@@ -797,6 +819,10 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
||||
#pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden;
|
||||
font-size:12px;color:var(--faint)}
|
||||
/* What a pull started, for a couple of seconds after letting go, under the top bar. */
|
||||
/* A list being waited for (#247): the pull-to-refresh spinner, in the list's place. */
|
||||
.listload{display:flex;align-items:center;justify-content:center;gap:8px;padding:28px 8px;color:var(--dim);font-size:13px}
|
||||
.listload::before{content:"";width:12px;height:12px;border-radius:50%;
|
||||
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite}
|
||||
#pullspin{position:fixed;left:50%;top:calc(58px + var(--safe-t));transform:translateX(-50%);z-index:55;
|
||||
display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;font-size:12px;
|
||||
color:var(--dim);background:var(--panel);border:1px solid var(--line);box-shadow:var(--shadow)}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import swc from '@swc/core';
|
||||
import html from '@swc/html';
|
||||
@@ -23,7 +24,7 @@ const WEB_VERSION = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.js
|
||||
// The files are one script, concatenated in this order, not modules: they share one top-level
|
||||
// scope, as the single inline script did, and code that runs at load needs what came before it.
|
||||
const PAGES = {
|
||||
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events', 'native'] },
|
||||
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'signals', 'gestures', 'events', 'native'] },
|
||||
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
||||
'login.html': { script: 'login.js', src: ['login'] },
|
||||
};
|
||||
@@ -37,6 +38,23 @@ const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 1
|
||||
const HLS = fs.readFileSync(path.join(here, '..', 'node_modules', 'hls.js', 'dist', 'hls.light.min.js'), 'utf8');
|
||||
const HLS_URL = `/hls.js?v=${hash(HLS)}`;
|
||||
|
||||
// Font Awesome Free, the set the page's own icons come from, whole, for a Signal's icon (#261):
|
||||
// each icon a file of its own, /icons/<style>/<name>.svg, drawn as a mask so it takes the theme's
|
||||
// colour, and an index of names and search terms the picker fetches when it opens. The page's
|
||||
// script is given the index's address, named by its hash, in place of __ICONS__.
|
||||
const FA = path.join(here, '..', 'node_modules', '@fortawesome', 'fontawesome-free');
|
||||
const FA_STYLES = ['solid', 'regular', 'brands'];
|
||||
/// [`style/name`, label, search terms] for every icon, solid first.
|
||||
export function iconIndex() {
|
||||
const meta = JSON.parse(fs.readFileSync(path.join(FA, 'metadata', 'icon-families.json'), 'utf8'));
|
||||
return FA_STYLES.flatMap(style => fs.readdirSync(path.join(FA, 'svgs', style)).sort().map(f => {
|
||||
const name = f.slice(0, -4), m = meta[name] || {};
|
||||
return [`${style}/${name}`, m.label || name, (m.search?.terms || []).join(' ')];
|
||||
}));
|
||||
}
|
||||
const ICONS = JSON.stringify(iconIndex());
|
||||
const ICONS_URL = `/icons/index.json?v=${hash(ICONS)}`;
|
||||
|
||||
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
|
||||
/// one; the doctype only keeps it from complaining that a fragment has none.
|
||||
export function buildStyle({ minify = true } = {}) {
|
||||
@@ -60,6 +78,7 @@ export function buildPage(name, { minify = true } = {}) {
|
||||
const { script, src: files } = PAGES[name];
|
||||
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n')
|
||||
.replaceAll('__HLS_JS__', HLS_URL)
|
||||
.replaceAll('__ICONS__', ICONS_URL)
|
||||
.replaceAll('__WEB_VERSION__', WEB_VERSION);
|
||||
const js = swc.transformSync(src, {
|
||||
filename: name + '.ts',
|
||||
@@ -104,13 +123,29 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
// nothing here shows any more but the classic theme.
|
||||
for (const [from, to] of [
|
||||
['logo.svg', 'logo.svg'], ['logo-dark.svg', 'logo-dark.svg'],
|
||||
...['signals', 'deadair-on', 'deadair-off'].flatMap(n => [`${n}.svg`, `${n}-dark.svg`]).map(f => [f, f]),
|
||||
['favicon.png', 'favicon.png'], ['favicon.png', 'favicon.ico'], ['favicon-dark.png', 'favicon-dark.png'],
|
||||
['apple-touch-icon.png', 'apple-touch-icon.png'], ['apple-touch-icon.png', 'apple-touch-icon-precomposed.png'],
|
||||
['ipodderx-icon.png', 'icon.png'], ['boot.js', 'boot.js'], ['InterVariable.woff2', 'inter.woff2'], ['Inter-LICENSE.txt', 'Inter-LICENSE.txt'],
|
||||
]) fs.copyFileSync(path.join(here, from), path.join(out, to));
|
||||
for (const style of FA_STYLES) {
|
||||
fs.mkdirSync(path.join(out, 'icons', style), { recursive: true });
|
||||
for (const f of fs.readdirSync(path.join(FA, 'svgs', style))) fs.copyFileSync(path.join(FA, 'svgs', style, f), path.join(out, 'icons', style, f));
|
||||
}
|
||||
fs.writeFileSync(path.join(out, 'icons', 'index.json'), ICONS);
|
||||
fs.copyFileSync(path.join(FA, 'LICENSE.txt'), path.join(out, 'icons', 'LICENSE.txt'));
|
||||
for (const name of Object.keys(PAGES)) {
|
||||
const { html, js, script } = buildPage(name);
|
||||
fs.writeFileSync(path.join(out, name), html);
|
||||
fs.writeFileSync(path.join(out, script), js);
|
||||
}
|
||||
// Each text file compressed once here, as hard as it goes, brotli and gzip beside it, for Caddy
|
||||
// to send in its place (#253): smaller than compressing each answer as it goes, and no work then.
|
||||
for (const f of fs.readdirSync(out).filter(f => /\.(html|js|css|svg|txt)$/.test(f))) {
|
||||
const body = fs.readFileSync(path.join(out, f));
|
||||
fs.writeFileSync(path.join(out, f + '.br'), zlib.brotliCompressSync(body, {
|
||||
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: body.length },
|
||||
}));
|
||||
fs.writeFileSync(path.join(out, f + '.gz'), zlib.gzipSync(body, { level: 9 }));
|
||||
}
|
||||
}
|
||||
|
||||
17
web/deadair-off-dark.svg
Normal file
17
web/deadair-off-dark.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<!-- deadair-off.svg's dark appearance: the same layers in the same places, only recoloured. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#1d4468"/>
|
||||
<stop offset="1" stop-color="#0c2238"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||
<g id="waveform" fill="#8fc2ea">
|
||||
<rect x="120" y="336" width="112" height="352" rx="56" opacity=".3"/>
|
||||
<rect x="264" y="208" width="112" height="608" rx="56" opacity=".5"/>
|
||||
<rect x="648" y="208" width="112" height="608" rx="56" opacity=".5"/>
|
||||
<rect x="792" y="336" width="112" height="352" rx="56" opacity=".3"/>
|
||||
</g>
|
||||
<g id="silence"><rect x="424" y="464" width="176" height="96" rx="48" fill="#ff6a1a"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 868 B |
18
web/deadair-off.svg
Normal file
18
web/deadair-off.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<!-- DeadAir off: deadair-on.svg's waveform with the silence still in its middle, a flat line,
|
||||
its bars muted, as the page's off glyph is only outlined; the silence keeps the orange. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#dff0fb"/>
|
||||
<stop offset="1" stop-color="#9fccef"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||
<g id="waveform" fill="#2f6aa0">
|
||||
<rect x="120" y="336" width="112" height="352" rx="56" opacity=".3"/>
|
||||
<rect x="264" y="208" width="112" height="608" rx="56" opacity=".5"/>
|
||||
<rect x="648" y="208" width="112" height="608" rx="56" opacity=".5"/>
|
||||
<rect x="792" y="336" width="112" height="352" rx="56" opacity=".3"/>
|
||||
</g>
|
||||
<g id="silence"><rect x="424" y="464" width="176" height="96" rx="48" fill="#ff5500"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 965 B |
17
web/deadair-on-dark.svg
Normal file
17
web/deadair-on-dark.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<!-- deadair-on.svg's dark appearance: the same layers in the same places, only recoloured. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#1d4468"/>
|
||||
<stop offset="1" stop-color="#0c2238"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||
<g id="waveform" fill="#8fc2ea">
|
||||
<rect x="188" y="336" width="112" height="352" rx="56" opacity=".55"/>
|
||||
<rect x="332" y="208" width="112" height="608" rx="56" opacity=".85"/>
|
||||
<rect x="580" y="208" width="112" height="608" rx="56" opacity=".85"/>
|
||||
<rect x="724" y="336" width="112" height="352" rx="56" opacity=".55"/>
|
||||
</g>
|
||||
<g id="needle"><rect x="476" y="160" width="72" height="704" rx="36" fill="#ff6a1a"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 870 B |
20
web/deadair-on.svg
Normal file
20
web/deadair-on.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<!-- DeadAir on, drawn as logo.svg is (square, opaque, one background and flat hard-edged
|
||||
layers, nothing thin enough to vanish at 32px) from the page's own DeadAir glyph: the
|
||||
waveform closed up where its silence was, the cut marked by the needle in orange.
|
||||
deadair-off.svg is the same waveform with the silence still in it. Each <g> is a layer. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#dff0fb"/>
|
||||
<stop offset="1" stop-color="#9fccef"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||
<g id="waveform" fill="#2f6aa0">
|
||||
<rect x="188" y="336" width="112" height="352" rx="56" opacity=".55"/>
|
||||
<rect x="332" y="208" width="112" height="608" rx="56" opacity=".85"/>
|
||||
<rect x="580" y="208" width="112" height="608" rx="56" opacity=".85"/>
|
||||
<rect x="724" y="336" width="112" height="352" rx="56" opacity=".55"/>
|
||||
</g>
|
||||
<g id="needle"><rect x="476" y="160" width="72" height="704" rx="36" fill="#ff5500"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
15
web/signals-dark.svg
Normal file
15
web/signals-dark.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<!-- signals.svg's dark appearance: the same layers in the same places, only recoloured. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#1d4468"/>
|
||||
<stop offset="1" stop-color="#0c2238"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||
<g id="waves" fill="none" stroke="#8fc2ea" stroke-width="112" stroke-linecap="round">
|
||||
<path d="M293 252A340 340 0 0 0 293 772M731 252A340 340 0 0 1 731 772" opacity=".5"/>
|
||||
<path d="M377 351A210 210 0 0 0 377 673M647 351A210 210 0 0 1 647 673" opacity=".85"/>
|
||||
</g>
|
||||
<g id="point"><circle cx="512" cy="512" r="96" fill="#ff6a1a"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 779 B |
17
web/signals.svg
Normal file
17
web/signals.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<!-- The Signals folder's tile, drawn as logo.svg is (square, opaque, one background and flat
|
||||
hard-edged layers, nothing thin enough to vanish at 32px) from the page's own Signals
|
||||
glyph: waves going out from a point, the point in the needle's orange. Each <g> is a layer. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#dff0fb"/>
|
||||
<stop offset="1" stop-color="#9fccef"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||
<g id="waves" fill="none" stroke="#2f6aa0" stroke-width="112" stroke-linecap="round">
|
||||
<path d="M293 252A340 340 0 0 0 293 772M731 252A340 340 0 0 1 731 772" opacity=".5"/>
|
||||
<path d="M377 351A210 210 0 0 0 377 673M647 351A210 210 0 0 1 647 673" opacity=".85"/>
|
||||
</g>
|
||||
<g id="point"><circle cx="512" cy="512" r="96" fill="#ff5500"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 976 B |
@@ -56,17 +56,17 @@ async function drawServer(){
|
||||
<div class="field"><label>Download folder</label>
|
||||
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
||||
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
|
||||
$('#gsave').onclick = async () => {
|
||||
$<HTMLButtonElement>('#gsave').onclick = async () => {
|
||||
try{
|
||||
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
|
||||
schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`,
|
||||
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
|
||||
media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean),
|
||||
max_total_gb: Number($('#gquota').value) || 0,
|
||||
max_age_days: Number($('#gage').value) || 0,
|
||||
art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})});
|
||||
schedule: `every ${Math.max(1, Number($<HTMLInputElement>('#gnum').value) || 1)}${$<HTMLSelectElement>('#gunit').value}`,
|
||||
max_new_per_check: Math.max(0, Number($<HTMLInputElement>('#gmax').value) || 0),
|
||||
media_types: $<HTMLInputElement>('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
|
||||
max_total_gb: Number($<HTMLInputElement>('#gquota').value) || 0,
|
||||
max_age_days: Number($<HTMLInputElement>('#gage').value) || 0,
|
||||
art_cache_mb: Math.max(0, Number($<HTMLInputElement>('#gart').value) || 0)})});
|
||||
toast('Settings saved');
|
||||
}catch(e){ toast(e.message, true); }
|
||||
}catch(e){ toast(why(e), true); }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,15 +99,15 @@ async function drawDirectorySettings(){
|
||||
await api('/api/settings', {method: 'PATCH', body: JSON.stringify(patch)});
|
||||
toast(said);
|
||||
drawDirectorySettings();
|
||||
}catch(e){ toast(e.message, true); }
|
||||
}catch(e){ toast(why(e), true); }
|
||||
};
|
||||
$('#dsave').onclick = () => {
|
||||
const patch: Record<string, unknown> = {directory_url: $('#durl').value.trim(), directory_share: $('#dshare').checked};
|
||||
$<HTMLButtonElement>('#dsave').onclick = () => {
|
||||
const patch: Record<string, unknown> = {directory_url: $<HTMLInputElement>('#durl').value.trim(), directory_share: $<HTMLInputElement>('#dshare').checked};
|
||||
// Blank leaves the token as it is; Clear is the way to remove it.
|
||||
if($('#dtoken').value.trim()) patch.directory_token = $('#dtoken').value.trim();
|
||||
if($<HTMLInputElement>('#dtoken').value.trim()) patch.directory_token = $<HTMLInputElement>('#dtoken').value.trim();
|
||||
save(patch, 'Directory settings saved');
|
||||
};
|
||||
const clear = $('#dclear');
|
||||
const clear = $<HTMLButtonElement>('#dclear');
|
||||
if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared');
|
||||
drawDirectoryStatus();
|
||||
}
|
||||
@@ -115,7 +115,7 @@ async function drawDirectorySettings(){
|
||||
async function drawDirectoryStatus(){
|
||||
const el = $('#dstatus');
|
||||
let s;
|
||||
try{ s = await api('/api/directory-status'); }catch(e){ el.textContent = e.message; return; }
|
||||
try{ s = await api('/api/directory-status'); }catch(e){ el.textContent = why(e); return; }
|
||||
if(!el.isConnected) return;
|
||||
const lines: string[] = [];
|
||||
if(!s.configured) lines.push('Not set up: it needs an address and a token.');
|
||||
@@ -137,8 +137,8 @@ async function drawDirectoryStatus(){
|
||||
/// sharing with it, and the feeds removed from it.
|
||||
async function drawDirectoryAdmin(){
|
||||
const box = $('#dadmin');
|
||||
let a;
|
||||
try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `<p class="hint">${esc(e.message)}</p>`; return; }
|
||||
let a: DirAdmin;
|
||||
try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `<p class="hint">${esc(why(e))}</p>`; return; }
|
||||
if(!box.isConnected) return;
|
||||
box.innerHTML = `<h2>Servers</h2>
|
||||
<p class="hint">Each server sharing with the directory, by its token's name. Dropping one removes its
|
||||
@@ -155,29 +155,29 @@ async function drawDirectoryAdmin(){
|
||||
<small style="display:block;color:var(--faint)">Removed ${dateOf(b.at)}</small></div>
|
||||
<button class="btn" data-a="unblock">Unblock</button></div>`).join('')
|
||||
: '<p class="hint">Nothing is blocked.</p>'}`;
|
||||
box.onclick = async ev => {
|
||||
box.onclick = async (ev: MouseEvent) => {
|
||||
const b = (ev.target as Element).closest<HTMLElement>('[data-a]');
|
||||
if(!b) return;
|
||||
const row = b.closest<HTMLElement>('.urow');
|
||||
try{
|
||||
if(b.dataset.a === 'drop'){
|
||||
const name = row.dataset.name;
|
||||
const name = row!.dataset.name!;
|
||||
if(!confirm(`Drop ${name}'s feeds and counts from the directory? They come back the next time ${name} shares.`)) return;
|
||||
await api(`/api/directory-instances/${encodeURIComponent(name)}`, {method: 'DELETE'});
|
||||
toast(`Dropped ${name}'s snapshot`);
|
||||
}else if(b.dataset.a === 'unblock'){
|
||||
await api(`/api/directory-blocked/${encodeURIComponent(row.dataset.id)}`, {method: 'DELETE'});
|
||||
await api(`/api/directory-blocked/${encodeURIComponent(row!.dataset.id!)}`, {method: 'DELETE'});
|
||||
toast('Unblocked. It comes back with the next snapshot that has it');
|
||||
}
|
||||
drawDirectoryAdmin();
|
||||
}catch(e){ toast(e.message, true); }
|
||||
}catch(e){ toast(why(e), true); }
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------- accounts ---------------- */
|
||||
async function drawAccounts(){
|
||||
const box = $('#accounts');
|
||||
const users = await api('/api/users') || [];
|
||||
const users: Account[] = await api('/api/users') || [];
|
||||
box.innerHTML = `<h2>Accounts</h2>
|
||||
${users.map(u => `<div class="inline urow" data-id="${u.id}">
|
||||
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b>
|
||||
@@ -195,44 +195,44 @@ async function drawAccounts(){
|
||||
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in
|
||||
through the proxy. New people start with no feeds.</span></div>
|
||||
<div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`;
|
||||
const change = async (u, opts) => {
|
||||
const change = async (u: Account, opts: RequestInit) => {
|
||||
try{
|
||||
await api(`/api/users/${u.id}`, opts);
|
||||
// Demoting yourself takes this page away; go back to the app rather than stay on a page
|
||||
// the server no longer answers. Only on success: a refusal's toast has to stay readable.
|
||||
if(u.name === me?.name){ location.href = '/'; return; }
|
||||
}catch(e){ toast(e.message, true); }
|
||||
}catch(e){ toast(why(e), true); }
|
||||
drawAccounts(); // on a refusal, this puts the checkbox back where the server left it
|
||||
};
|
||||
for(const row of $$('#accounts [data-id]')){
|
||||
const u = users.find(x => String(x.id) === row.dataset.id);
|
||||
$('[data-a="admin"]', row).onchange = e =>
|
||||
change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})});
|
||||
const u = users.find(x => String(x.id) === row.dataset.id)!;
|
||||
$('[data-a="admin"]', row).onchange = (e: Event) =>
|
||||
change(u, {method: 'PATCH', body: JSON.stringify({admin: (e.target as HTMLInputElement).checked})});
|
||||
$('[data-a="rm"]', row).onclick = () => {
|
||||
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
|
||||
change(u, {method: 'DELETE'});
|
||||
};
|
||||
}
|
||||
$('#uadd').onclick = async () => {
|
||||
$<HTMLButtonElement>('#uadd').onclick = async () => {
|
||||
try{
|
||||
await api('/api/users', {method: 'POST', body: JSON.stringify({
|
||||
name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})});
|
||||
name: $<HTMLInputElement>('#uname').value, password: $<HTMLInputElement>('#upass').value, admin: $<HTMLInputElement>('#uadmin').checked})});
|
||||
toast('Added'); drawAccounts();
|
||||
}catch(e){ toast(e.message, true); } // keep what was typed
|
||||
}catch(e){ toast(why(e), true); } // keep what was typed
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------- log ---------------- */
|
||||
let logTimer = null, logSeq = 0, logLines = [], logFilter = '', logLevel = '', logTab = 'all';
|
||||
let logTimer: number|null = null, logSeq = 0, logLines: LogLine[] = [], logFilter = '', logLevel = '', logTab = 'all';
|
||||
// Which sources belong to each tab. "daemon" is the control protocol itself: every
|
||||
// command in and every event out, whatever sent it.
|
||||
const LOG_TABS = {
|
||||
const LOG_TABS: Record<string, ((t: string) => boolean)|null> = {
|
||||
all: null,
|
||||
daemon: t => t === 'ipx::io',
|
||||
scan: t => t === 'ipx::scan',
|
||||
web: t => t === 'ipx::http',
|
||||
};
|
||||
const LEVELS = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
|
||||
const LEVELS: Record<string,number> = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
|
||||
|
||||
function drawLogView(){
|
||||
logSeq = 0; logLines = [];
|
||||
@@ -259,14 +259,14 @@ function drawLogView(){
|
||||
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
|
||||
what it captures.</span>`;
|
||||
for(const b of $$('#logtabs button')) b.onclick = () => {
|
||||
logTab = b.dataset.t;
|
||||
logTab = b.dataset.t!;
|
||||
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
|
||||
drawLog();
|
||||
};
|
||||
$('#loglevel').onchange = e => { logLevel = e.target.value; drawLog(); };
|
||||
$('#logq').oninput = e => { logFilter = e.target.value.toLowerCase(); drawLog(); };
|
||||
$('#logcopy').onclick = () => copyText(visibleLog().map(l =>
|
||||
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy'));
|
||||
$<HTMLSelectElement>('#loglevel').onchange = (e: Event) => { logLevel = (e.target as HTMLSelectElement).value; drawLog(); };
|
||||
$<HTMLInputElement>('#logq').oninput = (e: Event) => { logFilter = (e.target as HTMLInputElement).value.toLowerCase(); drawLog(); };
|
||||
$<HTMLButtonElement>('#logcopy').onclick = () => copyText(visibleLog().map(l =>
|
||||
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $<HTMLButtonElement>('#logcopy'));
|
||||
pollLog();
|
||||
if(!logTimer) logTimer = setInterval(pollLog, 2000);
|
||||
}
|
||||
@@ -281,7 +281,7 @@ async function pollLog(){
|
||||
}else if(!logLines.length){ drawLog(); }
|
||||
}catch(e){
|
||||
const box = $('#logbox');
|
||||
if(box) box.innerHTML = `<p class="empty">Lost contact with the daemon: ${esc(e.message)}</p>`;
|
||||
if(box) box.innerHTML = `<p class="empty">Lost contact with the daemon: ${esc(why(e))}</p>`;
|
||||
}
|
||||
}
|
||||
function visibleLog(){
|
||||
@@ -294,7 +294,7 @@ function visibleLog(){
|
||||
}
|
||||
function drawLog(){
|
||||
const box = $('#logbox'); if(!box) return;
|
||||
const follow = $('#logfollow')?.checked;
|
||||
const follow = $<HTMLInputElement>('#logfollow')?.checked;
|
||||
const rows = visibleLog();
|
||||
box.innerHTML = rows.length ? rows.map(l => {
|
||||
const t = new Date(l.ts * 1000).toLocaleTimeString();
|
||||
|
||||
@@ -9,9 +9,9 @@ function closeModal(){
|
||||
$('#modal').classList.remove('on');
|
||||
}
|
||||
|
||||
$('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); };
|
||||
$('#modal').onclick=(e: MouseEvent)=>{ if((e.target as Element).id==='modal') closeModal(); };
|
||||
|
||||
$('#addFeed').onclick=()=>{
|
||||
$<HTMLButtonElement>('#addFeed').onclick=()=>{
|
||||
openModal(`<h3>Add a feed</h3>
|
||||
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
|
||||
<span class="hint">A Patreon token on its own adds every show from that creator.</span></div>
|
||||
@@ -20,17 +20,17 @@ $('#addFeed').onclick=()=>{
|
||||
<label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label>
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
<button class="btn ico primary" id="nsave" title="Add feed" aria-label="Add feed">${ICON.plus}</button></div>`);
|
||||
$('#nurl').focus();
|
||||
$('#nsave').onclick=async()=>{
|
||||
const url=$('#nurl').value.trim(); if(!url) return;
|
||||
$('#nsave').disabled=true; $('#nsave').title='Adding…';
|
||||
$<HTMLInputElement>('#nurl').focus();
|
||||
$<HTMLButtonElement>('#nsave').onclick=async()=>{
|
||||
const url=$<HTMLInputElement>('#nurl').value.trim(); if(!url) return;
|
||||
$<HTMLButtonElement>('#nsave').disabled=true; $<HTMLButtonElement>('#nsave').title='Adding…';
|
||||
try{
|
||||
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
|
||||
url, allow_explicit:$('#nexp').checked,
|
||||
keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})});
|
||||
url, allow_explicit:$<HTMLInputElement>('#nexp').checked,
|
||||
keywords:$<HTMLInputElement>('#nkw').value.split(',').map((s: string)=>s.trim()).filter(Boolean)})});
|
||||
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
|
||||
await loadFeeds(true); selectFeed(r.id);
|
||||
}catch(e){ toast(e.message,true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; }
|
||||
}catch(e){ toast(why(e),true); $<HTMLButtonElement>('#nsave').title='Add feed'; $<HTMLButtonElement>('#nsave').disabled=false; }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ let dirError='';
|
||||
/// One listed feed: a tile in the Directory's grid or, given its place, a row in its chart. The
|
||||
/// parts are the same either way; the class lays them out. Yours opens its feed; any other, its
|
||||
/// page in the Directory, to see what it is before you take it (#128).
|
||||
function listedFeed(p,cls,rank?: number){
|
||||
function listedFeed(p: DirFeed,cls: string,rank?: number){
|
||||
const el=document.createElement('div');
|
||||
el.className=cls+(p.subscribed?' mine':'');
|
||||
el.tabIndex=0; el.dataset.id=p.id;
|
||||
@@ -64,49 +64,59 @@ function listedFeed(p,cls,rank?: number){
|
||||
if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; }
|
||||
el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); };
|
||||
// Its own click, not the tile's as well.
|
||||
$('[data-a="sub"]',el).onclick=ev=>{ ev.stopPropagation(); subscribeListed(p); };
|
||||
$('[data-a="sub"]',el).onclick=(ev: Event)=>{ ev.stopPropagation(); subscribeListed(p); };
|
||||
return el;
|
||||
}
|
||||
|
||||
async function subscribeListed(p){
|
||||
async function subscribeListed(p: DirFeed){
|
||||
try{
|
||||
const r=await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
|
||||
toast(`Subscribed to ${NAME(p)}`);
|
||||
await loadFeeds(true); selectFeed(r.id);
|
||||
}catch(e){ toast(e.message,true); }
|
||||
}catch(e){ toast(why(e),true); }
|
||||
}
|
||||
|
||||
// Where you are in the Directory: what kind of feed, which category and subcategory, in what
|
||||
// order. Out here because a search redraws the page, and the kind and order hold across visits.
|
||||
let dirKind='All', dirCat=null, dirSub=null, dirSort='az', dirRows=[];
|
||||
let dirKind='All', dirCat: string|null=null, dirSub: string|null=null, dirSort='az', dirRows: DirFeed[]=[];
|
||||
// The feed whose page is open over all that, by id; going back leaves the rest as it was.
|
||||
let dirShow=null;
|
||||
let dirShow: string|null=null;
|
||||
// The two pages that are not a category: every feed, and the feeds that name none. A category is
|
||||
// Apple's or one an admin typed, neither of which starts with a colon.
|
||||
const EVERY=':every', OTHER=':other';
|
||||
const NAME=p=>(p.title||p.id);
|
||||
const byName=(a,b)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true});
|
||||
const bySubs=(a,b)=>b.subscribers-a.subscribers||byName(a,b);
|
||||
const NAME=(p: {title: string|null, id: string})=>(p.title||p.id);
|
||||
const byName=(a: DirFeed,b: DirFeed)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true});
|
||||
const bySubs=(a: DirFeed,b: DirFeed)=>b.subscribers-a.subscribers||byName(a,b);
|
||||
/// The grid's orders. It opens A to Z, as the server sends it.
|
||||
const DIR_SORTS={
|
||||
const DIR_SORTS: Record<string,[string,(a: DirFeed,b: DirFeed)=>number]>={
|
||||
subscribers:['Most subscribers',bySubs],
|
||||
az:['Name, A to Z',byName],
|
||||
za:['Name, Z to A',(a,b)=>byName(b,a)],
|
||||
za:['Name, Z to A',(a: DirFeed,b: DirFeed)=>byName(b,a)],
|
||||
};
|
||||
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
|
||||
const KIND_WORD={All:'feed',Podcasts:'podcast',Blogs:'blog'};
|
||||
const KINDS: Record<string,(p: DirFeed)=>boolean>={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
|
||||
const KIND_WORD: Record<string,string>={All:'feed',Podcasts:'podcast',Blogs:'blog'};
|
||||
|
||||
/// The Directory, as Apple's is laid out: it opens on the most subscribed feeds and the
|
||||
/// categories, and a category has a page of its own, its subcategories along the top of its
|
||||
/// grid. What a feed is (Podcasts, anything with audio or video, or Blogs, the rest) holds across
|
||||
/// every page, and the search box looks through the lot. One fetch draws all of it.
|
||||
async function renderDirectory(url,box){
|
||||
async function renderDirectory(url: string,box: HTMLElement,counted: (n: number)=>void){
|
||||
// Each visit starts at the front, as going back to a tab does.
|
||||
dirCat=null; dirSub=null; dirShow=null;
|
||||
dirError='';
|
||||
try{ dirRows=await api(url)||[]; }catch(e){ dirRows=[]; dirError=e.message; }
|
||||
// Drawn at once from what was kept or fetched ahead, then again only if the catalogue changed
|
||||
// (#248); with nothing kept, a spinner until it comes.
|
||||
const kept=pages.get(url);
|
||||
if(kept){ dirRows=kept; drawDirectory(box); counted(dirRows.length); }
|
||||
else listLoading(box);
|
||||
try{
|
||||
const fresh=await fetchPage(url)||[];
|
||||
if(kept&&JSON.stringify(kept)===JSON.stringify(fresh)) return;
|
||||
if(!box.isConnected) return;
|
||||
dirRows=fresh;
|
||||
}catch(e){ if(kept) return; dirRows=[]; dirError=why(e); }
|
||||
drawDirectory(box);
|
||||
return dirRows.length;
|
||||
counted(dirRows.length);
|
||||
}
|
||||
|
||||
/// `focus` is the control to put the keyboard back on, since the whole page is redrawn.
|
||||
@@ -117,24 +127,24 @@ function drawDirectory(box=$('#directory'), focus?: string){
|
||||
const both=dirRows.some(KINDS.Podcasts)&&dirRows.some(KINDS.Blogs);
|
||||
if(!both) dirKind='All';
|
||||
const rows=dirRows.filter(KINDS[dirKind]), word=KIND_WORD[dirKind];
|
||||
const count=n=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`;
|
||||
const of=c=>p=>c===EVERY||(c===OTHER?!p.category:p.category===c);
|
||||
const count=(n: number)=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`;
|
||||
const of=(c: string)=>(p: DirFeed)=>c===EVERY||(c===OTHER?!p.category:p.category===c);
|
||||
// A kind can leave a category with nothing in it; that goes back to the front.
|
||||
if(dirCat&&!rows.some(of(dirCat))) dirCat=null;
|
||||
const catName=c=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c;
|
||||
const top=list=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10);
|
||||
const btn=(k,v,label,on)=>`<button type="button" data-${k}="${esc(v)}" aria-pressed="${on}"${on?' class="on"':''}>${label}</button>`;
|
||||
const catName=(c: string)=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c;
|
||||
const top=(list: DirFeed[])=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10);
|
||||
const btn=(k: string,v: string,label: string,on: boolean)=>`<button type="button" data-${k}="${esc(v)}" aria-pressed="${on}"${on?' class="on"':''}>${label}</button>`;
|
||||
// back: where its button goes, '' for the front; none on the front itself.
|
||||
const head=(title,sub,back?)=>`<div class="dhead">${back==null?'':
|
||||
const head=(title: string,sub: string,back?: string)=>`<div class="dhead">${back==null?'':
|
||||
`<button type="button" class="dback" data-go="${esc(back)}">${ICON.left}${esc(back?catName(back):'Directory')}</button>`}
|
||||
<div class="dtitle"><h2>${esc(title)}</h2>${both?`<div class="tabs" role="group" aria-label="Kind">${
|
||||
Object.keys(KINDS).map(k=>btn('kind',k,k,k===dirKind)).join('')}</div>`:''}</div>
|
||||
<p class="sub">${esc(sub)}</p></div>`;
|
||||
const sec=(title,body,extra='')=>`<section class="dsec">${title?`<div class="dsech"><h3>${esc(title)}</h3>${extra}</div>`:''}${body}</section>`;
|
||||
const sec=(title: string,body: string,extra='')=>`<section class="dsec">${title?`<div class="dsech"><h3>${esc(title)}</h3>${extra}</div>`:''}${body}</section>`;
|
||||
const tools=(pills='')=>`<div class="dtools">${pills||'<span class="grow"></span>'}<select id="dirsort" aria-label="Sort">${
|
||||
Object.entries(DIR_SORTS).map(([k,[label]])=>`<option value="${k}"${k===dirSort?' selected':''}>${label}</option>`).join('')}</select></div>`;
|
||||
const q=S.q.trim(), grid='<div class="tiles" id="dtiles"></div>', board='<div class="chart" id="dchart"></div>';
|
||||
let html, chart=[], tiles=null;
|
||||
let html: string, chart: DirFeed[]=[], tiles: DirFeed[]|null=null;
|
||||
const show=dirShow&&dirRows.find(p=>p.id===dirShow);
|
||||
if(show){
|
||||
const n=show.subscribers;
|
||||
@@ -154,21 +164,21 @@ function drawDirectory(box=$('#directory'), focus?: string){
|
||||
html=head(`Results for “${q}”`,tiles.length?count(tiles.length):`No ${word} here has that in its name.`,dirCat||'')+tools()+grid;
|
||||
}else if(!dirCat){
|
||||
chart=top(rows);
|
||||
const cats=new Map<string,any[]>();
|
||||
for(const p of rows){ const c=p.category||OTHER; if(!cats.has(c)) cats.set(c,[]); cats.get(c).push(p); }
|
||||
const cats=new Map<string,DirFeed[]>();
|
||||
for(const p of rows){ const c=p.category||OTHER; if(!cats.has(c)) cats.set(c,[]); cats.get(c)!.push(p); }
|
||||
// Other, the feeds with no category, goes last.
|
||||
const order=[...cats.keys()].sort((a,b)=>Number(a===OTHER)-Number(b===OTHER)||a.localeCompare(b));
|
||||
html=head('Directory',`${count(rows.length)} shared by iPX servers. Private feeds are never listed.`)
|
||||
+(chart.length?sec('Most subscribed',board):'')
|
||||
+sec('Categories',`<div class="cats">${order.map(c=>catTile(c,catName(c),count(cats.get(c).length),cats.get(c))).join('')}</div>`,
|
||||
+sec('Categories',`<div class="cats">${order.map(c=>catTile(c,catName(c),count(cats.get(c)!.length),cats.get(c)!)).join('')}</div>`,
|
||||
`<button type="button" class="dall" data-go="${EVERY}">See all ${rows.length.toLocaleString()}</button>`);
|
||||
}else{
|
||||
const inCat=rows.filter(of(dirCat));
|
||||
const subs=dirCat===EVERY?[]:[...new Set(inCat.map(p=>p.subcategory).filter(Boolean))].sort();
|
||||
if(!subs.includes(dirSub)) dirSub=null;
|
||||
const subs=dirCat===EVERY?[]:[...new Set(inCat.map(p=>p.subcategory).filter((s): s is string=>!!s))].sort();
|
||||
if(!dirSub||!subs.includes(dirSub)) dirSub=null;
|
||||
if(dirCat!==EVERY) chart=top(inCat);
|
||||
tiles=inCat.filter(p=>!dirSub||p.subcategory===dirSub);
|
||||
const pill=(v,label,n)=>btn('sub',v,`${esc(label)}<small>${n}</small>`,(dirSub||'')===v);
|
||||
const pill=(v: string,label: string,n: number)=>btn('sub',v,`${esc(label)}<small>${n}</small>`,(dirSub||'')===v);
|
||||
html=head(catName(dirCat),dirCat===OTHER?`${count(inCat.length)} that name no category`:count(inCat.length),'')
|
||||
+(chart.length?sec('Most subscribed',board):'')
|
||||
// Its own heading only when the chart is above it, to say where one list ends.
|
||||
@@ -178,25 +188,25 @@ function drawDirectory(box=$('#directory'), focus?: string){
|
||||
box.innerHTML=html;
|
||||
if(show){ $('.show [data-a="sub"]',box).onclick=()=>subscribeListed(show); fillShow(box,show); }
|
||||
const c=$('#dchart',box);
|
||||
if(c){ c.style.setProperty('--rows',String(Math.ceil(chart.length/2))); chart.forEach((p,i)=>c.appendChild(listedFeed(p,'rank',i+1))); }
|
||||
if(c){ c.style.setProperty('--rows',String(Math.ceil(chart.length/2))); chart.forEach((p: DirFeed,i: number)=>c.appendChild(listedFeed(p,'rank',i+1))); }
|
||||
const t=$('#dtiles',box);
|
||||
if(t) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile'));
|
||||
box.onclick=ev=>{
|
||||
if(t&&tiles) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile'));
|
||||
box.onclick=(ev: MouseEvent)=>{
|
||||
const b=(ev.target as Element).closest<HTMLElement>('[data-go],[data-kind],[data-sub],[data-back]');
|
||||
if(!b) return;
|
||||
// Back from a feed's page, onto the tile or row it was opened from.
|
||||
if(b.dataset.back!=null){ const id=dirShow; dirShow=null; return drawDirectory(box,`[data-id="${CSS.escape(id)}"]`); }
|
||||
if(b.dataset.back!=null){ const id=dirShow??''; dirShow=null; return drawDirectory(box,`[data-id="${CSS.escape(id)}"]`); }
|
||||
if(b.dataset.kind!=null){ dirKind=b.dataset.kind; return drawDirectory(box,`[data-kind="${CSS.escape(dirKind)}"]`); }
|
||||
if(b.dataset.sub!=null){ dirSub=b.dataset.sub||null; return drawDirectory(box,`[data-sub="${CSS.escape(b.dataset.sub)}"]`); }
|
||||
// Going anywhere ends a search.
|
||||
if(S.q){ S.q=''; $('#epSearch').value=''; }
|
||||
if(S.q){ S.q=''; $<HTMLInputElement>('#epSearch').value=''; }
|
||||
const from=dirCat;
|
||||
dirCat=b.dataset.go||null; dirSub=null; dirShow=null;
|
||||
$('#content').scrollTop=0;
|
||||
// Into a page, onto its way back; back out, onto the tile you went in by.
|
||||
drawDirectory(box,dirCat?'.dback':from?`[data-go="${CSS.escape(from)}"]:not(.dback)`:undefined);
|
||||
};
|
||||
const s=$('#dirsort',box);
|
||||
const s=$<HTMLSelectElement>('#dirsort',box);
|
||||
if(s) s.onchange=()=>{ dirSort=s.value; drawDirectory(box,'#dirsort'); };
|
||||
if(focus) $(focus,box)?.focus();
|
||||
}
|
||||
@@ -204,11 +214,11 @@ function drawDirectory(box=$('#directory'), focus?: string){
|
||||
/// A listed feed's page, filled in: what it says it is, its first lines unless you ask for the
|
||||
/// rest, and its latest items, each one's title, opening its post on the publisher's site where
|
||||
/// it has one, its first lines, and when and how long.
|
||||
async function fillShow(box,p){
|
||||
async function fillShow(box: HTMLElement,p: DirFeed){
|
||||
const list=$('#dshow',box), about=$('#dabout',box);
|
||||
let got;
|
||||
let got: {description: string|null, failing: string|null, items: ListedItem[], can_admin: boolean};
|
||||
try{ got=await api(`/api/directory/${encodeURIComponent(p.id)}`); }
|
||||
catch(e){ list.innerHTML=`<p class="hint">${esc(e.message)}</p>`; return; }
|
||||
catch(e){ list.innerHTML=`<p class="hint">${esc(why(e))}</p>`; return; }
|
||||
// Gone back, or on to another, while it loaded.
|
||||
if(dirShow!==p.id||!list.isConnected) return;
|
||||
// Some feeds give their title again as their description, which says nothing new.
|
||||
@@ -233,12 +243,12 @@ async function fillShow(box,p){
|
||||
+`<button class="btn ico danger" data-a="dremove" title="Remove from the directory" aria-label="Remove from the directory">${ICON.trash}</button>`);
|
||||
$('[data-a="drefresh"]',acts).onclick=async()=>{
|
||||
try{ await api(`/api/directory/${encodeURIComponent(p.id)}/refresh`,{method:'POST'}); toast('Checked'); fillShow(box,p); }
|
||||
catch(e){ toast(e.message,true); }
|
||||
catch(e){ toast(why(e),true); }
|
||||
};
|
||||
$('[data-a="dremove"]',acts).onclick=async()=>{
|
||||
if(!confirm(`Remove ${NAME(p)} from the directory? It is blocked, so no server's snapshot brings it back. Admin, Directory can unblock it.`)) return;
|
||||
try{ await api(`/api/directory/${encodeURIComponent(p.id)}`,{method:'DELETE'}); }
|
||||
catch(e){ return toast(e.message,true); }
|
||||
catch(e){ return toast(why(e),true); }
|
||||
toast(`Removed ${NAME(p)} from the directory`);
|
||||
dirRows=dirRows.filter(r=>r.id!==p.id); dirShow=null;
|
||||
drawDirectory(box);
|
||||
@@ -256,7 +266,7 @@ async function fillShow(box,p){
|
||||
|
||||
/// A category as a tile: its name, how many feeds, and the covers of its three most subscribed
|
||||
/// fanned in the corner, so each one is coloured by what is in it, as Apple's are.
|
||||
function catTile(c,name,count,rows){
|
||||
function catTile(c: string,name: string,count: string,rows: DirFeed[]){
|
||||
// Covers before initials, which are all Other had; the likeliest first, and first is in front:
|
||||
// the fan is drawn back to front.
|
||||
const art=[...rows].sort((a,b)=>Number(!a.image)-Number(!b.image)||bySubs(a,b)).slice(0,3).reverse();
|
||||
@@ -265,43 +275,53 @@ function catTile(c,name,count,rows){
|
||||
}
|
||||
|
||||
/// The Directory and Currently Listening open in the main pane, as the original's Directory did.
|
||||
async function renderListed(v){
|
||||
async function renderListed(v: {title: string, icon: string, url?: string, blurb?: string}){
|
||||
const box=$('#content');
|
||||
box.classList.add('plain');
|
||||
$('#tbRemove').disabled=true;
|
||||
$<HTMLButtonElement>('#tbRemove').disabled=true;
|
||||
syncTools(null);
|
||||
if(v===VIEWS[':directory']){
|
||||
$('#epSearch').placeholder='Find a feed…';
|
||||
box.innerHTML='<div id="directory"><p class="hint">Loading…</p></div>';
|
||||
$<HTMLInputElement>('#epSearch').placeholder='Find a feed…';
|
||||
box.innerHTML='<div id="directory"></div>';
|
||||
$('#count').textContent=v.title;
|
||||
const n=await renderDirectory(v.url,$('#directory'));
|
||||
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`;
|
||||
await renderDirectory(v.url!,$('#directory'),n=>{ if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`; });
|
||||
return;
|
||||
}
|
||||
$('#epSearch').placeholder='Search items…';
|
||||
$<HTMLInputElement>('#epSearch').placeholder='Search items…';
|
||||
box.innerHTML=`
|
||||
<div class="fhead slim">
|
||||
<div class="art">${v.icon}</div>
|
||||
<div class="meta"><h2>${v.title}</h2><div class="sub">${v.blurb}</div></div>
|
||||
</div>
|
||||
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`;
|
||||
<div class="childlist" id="listening"></div>`;
|
||||
$('#count').textContent=v.title;
|
||||
// The search box looks through these too, title and text, as it does a feed's items (#127).
|
||||
const n=await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url,$('#listening',box));
|
||||
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`;
|
||||
await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url!,$('#listening',box),
|
||||
n=>{ if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`; });
|
||||
}
|
||||
|
||||
/// Currently Listening: episodes you started and have not finished, across every feed you
|
||||
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
|
||||
/// where you left off, not another way to browse. The one in the player pauses instead.
|
||||
async function renderListening(url,box){
|
||||
let rows=[];
|
||||
try{ rows=(await api(url)).entries||[]; }catch{}
|
||||
async function renderListening(url: string,box: HTMLElement,counted: (n: number)=>void){
|
||||
// Drawn at once from what was kept or fetched ahead, then again only if it changed (#248): it
|
||||
// took over a second to answer. With nothing kept, a spinner until it comes.
|
||||
const kept: {entries?: Entry[]}|undefined=pages.get(url);
|
||||
if(kept){ drawListening(box,kept.entries||[]); counted((kept.entries||[]).length); }
|
||||
else listLoading(box);
|
||||
let fresh: {entries?: Entry[]};
|
||||
try{ fresh=await fetchPage(url); }catch{ if(kept) return; fresh={}; }
|
||||
if(kept&&JSON.stringify(kept)===JSON.stringify(fresh)) return;
|
||||
if(!box.isConnected) return;
|
||||
drawListening(box,fresh.entries||[]);
|
||||
counted((fresh.entries||[]).length);
|
||||
}
|
||||
function drawListening(box: HTMLElement,rows: Entry[]){
|
||||
box.innerHTML=rows.length?'':S.q?`<p class="hint">Nothing you are listening to mentions “${esc(S.q)}”.</p>`
|
||||
:'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
|
||||
for(const e of rows){
|
||||
// Carries its episode, for paintListenRow to repaint as the player moves.
|
||||
const el: HTMLDivElement & {entry?: any}=document.createElement('div');
|
||||
const el: HTMLDivElement & {entry?: Entry}=document.createElement('div');
|
||||
el.className='childrow';
|
||||
el.entry=e;
|
||||
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
|
||||
@@ -315,12 +335,11 @@ async function renderListening(url,box){
|
||||
paintListenRow(el);
|
||||
box.appendChild(el);
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
/// One row's time left, progress and play button, taken from the player when it is the one in it.
|
||||
function paintListenRow(el){
|
||||
const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id;
|
||||
function paintListenRow(el: HTMLElement & {entry?: Entry}){
|
||||
const e=el.entry!, now=player.guid===e.guid&&player.feed===e.feed_id;
|
||||
// Zero until the player has sought to where you left off; the saved position stands till then.
|
||||
if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime);
|
||||
// The player's own length first: a feed's can be minutes out.
|
||||
@@ -335,32 +354,34 @@ function paintListenRow(el){
|
||||
}
|
||||
/// Keeps the list in step with the player. Only a row that is, or was, the one in it changes.
|
||||
function syncListening(){
|
||||
for(const el of $$('#listening .childrow'))
|
||||
for(const el of $$<HTMLElement & {entry?: Entry}>('#listening .childrow'))
|
||||
if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el);
|
||||
}
|
||||
|
||||
/// Takes an episode off Currently Listening by forgetting where you got to: the list is every
|
||||
/// episode with a saved position short of the end, so the position is what has to go.
|
||||
async function forget(e){
|
||||
async function forget(e: Entry){
|
||||
// Closed without saving first, or the player's next save would put it straight back.
|
||||
if(player.guid===e.guid){ player.guid=null; $('#pclose').click(); }
|
||||
if(player.guid===e.guid){ player.guid=null; $<HTMLButtonElement>('#pclose').click(); }
|
||||
try{
|
||||
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
|
||||
{method:'POST',body:JSON.stringify({secs:0})});
|
||||
}catch(err){ toast(err.message,true); }
|
||||
}catch(err){ toast(why(err),true); }
|
||||
// The kept list still has it; drawn from that, it would show again until the refresh came.
|
||||
for(const k of [...pages.keys()]) if(k.startsWith(VIEWS[':listening'].url!)) pages.delete(k);
|
||||
if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
||||
}
|
||||
|
||||
// The toolbar acts on whatever is selected: the feed on the left, the item in the table.
|
||||
$('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); };
|
||||
$('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
|
||||
$('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
|
||||
$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
|
||||
let searchT;
|
||||
$('#epSearch').oninput=ev=>{ clearTimeout(searchT);
|
||||
$<HTMLButtonElement>('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); };
|
||||
$<HTMLButtonElement>('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
|
||||
$<HTMLButtonElement>('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
|
||||
$<HTMLButtonElement>('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
|
||||
let searchT: number;
|
||||
$<HTMLInputElement>('#epSearch').oninput=(ev: Event)=>{ clearTimeout(searchT);
|
||||
// The Directory looks through what it already has; Currently Listening and the rest ask the
|
||||
// server (#126, #127).
|
||||
searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0;
|
||||
searchT=setTimeout(()=>{ S.q=(ev.target as HTMLInputElement).value; S.offset=0;
|
||||
if(S.feed===':directory'){ dirShow=null; drawDirectory(); }
|
||||
else if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
||||
else loadEntries(); },250); };
|
||||
@@ -368,13 +389,13 @@ $('#epSearch').oninput=ev=>{ clearTimeout(searchT);
|
||||
window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); });
|
||||
|
||||
let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]'));
|
||||
function toggleGroup(id){
|
||||
function toggleGroup(id: string){
|
||||
expanded.has(id) ? expanded.delete(id) : expanded.add(id);
|
||||
try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{}
|
||||
renderFeeds();
|
||||
}
|
||||
let globalMax = 3;
|
||||
function due(ts){
|
||||
function due(ts: number){
|
||||
const d = ts - Date.now()/1000;
|
||||
if(d <= 0) return 'due now';
|
||||
if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m';
|
||||
@@ -385,7 +406,7 @@ function due(ts){
|
||||
/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does
|
||||
/// with feeds. The server's own settings, the accounts and the log are on /admin, which only an
|
||||
/// admin is sent (issue #19); this used to hold them too, shown to admins only.
|
||||
function deadSavedText(me){
|
||||
function deadSavedText(me: Me|null){
|
||||
const s=me&&me.deadair_saved||0;
|
||||
return s?`It has saved you ${spanText(s)} of dead air, on episodes you heard three quarters of or more.`
|
||||
:'Turn it on in a show\'s settings, or beside the speed while it plays, to cut the dead air from it.';
|
||||
@@ -419,6 +440,13 @@ async function prefsModal(){
|
||||
<span class="hint">Comma separated words or phrases, in every feed you read. An item with
|
||||
one in its title or text is hidden from you and not downloaded for you. Each feed's
|
||||
settings can add more.</span></div>
|
||||
<div class="field"><label for="stskey">TypeSafe key</label>
|
||||
<div class="inline"><input type="password" id="stskey" autocomplete="off" spellcheck="false"
|
||||
placeholder="${S.me?.typesafe_key_set?'Saved. Paste a new one to replace it':'Paste your key'}">
|
||||
<button class="btn ico" id="stsforget" title="Forget the key" aria-label="Forget the key"${S.me?.typesafe_key_set?'':' hidden'}>${ICON.trash}</button></div>
|
||||
<span class="hint">Signals pick what matters out of everything you subscribe to, on your own
|
||||
key from <a href="https://typesafe.ai" target="_blank" rel="noopener">typesafe.ai</a>.
|
||||
The key is kept on this server and never shown again.</span></div>
|
||||
<div class="field boxed"><label for="tokname">API tokens</label>
|
||||
<div id="tokens"></div>
|
||||
<div class="inline"><input type="text" id="tokname" placeholder="What it is for">
|
||||
@@ -429,45 +457,57 @@ async function prefsModal(){
|
||||
<div class="field"><label>Feeds are checked every</label>
|
||||
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
|
||||
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>`);
|
||||
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
||||
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
||||
$('#gopml').onclick=opmlModal;
|
||||
$<HTMLSelectElement>('#stheme').onchange=(e: Event)=>setTheme((e.target as HTMLSelectElement).value,undefined,true);
|
||||
$<HTMLSelectElement>('#smode').onchange=(e: Event)=>setTheme(undefined,(e.target as HTMLSelectElement).value,true);
|
||||
$<HTMLButtonElement>('#gopml').onclick=opmlModal;
|
||||
drawTokens();
|
||||
$('#tokadd').onclick=async()=>{
|
||||
const name=$('#tokname').value.trim(); if(!name){ $('#tokname').focus(); return; }
|
||||
$<HTMLButtonElement>('#tokadd').onclick=async()=>{
|
||||
const name=$<HTMLInputElement>('#tokname').value.trim(); if(!name){ $<HTMLInputElement>('#tokname').focus(); return; }
|
||||
try{
|
||||
const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name})});
|
||||
$('#tokname').value='';
|
||||
$<HTMLInputElement>('#tokname').value='';
|
||||
await drawTokens();
|
||||
// Shown this once: only its hash is kept, so a lost token is revoked and made again.
|
||||
$('#tokens').insertAdjacentHTML('afterbegin',`<div class="field" id="toknew"><span class="hint">Your new token,
|
||||
${esc(t.name)}. Copy it now: it is not shown again.</span>
|
||||
<div class="inline"><input type="text" id="tokval" readonly value="${esc(t.token)}">
|
||||
<button class="btn ico" id="tokcopy" title="Copy" aria-label="Copy">${ICON.copy}</button></div></div>`);
|
||||
$('#tokcopy').onclick=()=>copyText(t.token,$('#tokcopy'));
|
||||
$('#tokval').select();
|
||||
}catch(err){ toast(err.message,true); }
|
||||
$<HTMLButtonElement>('#tokcopy').onclick=()=>copyText(t.token,$<HTMLButtonElement>('#tokcopy'));
|
||||
$<HTMLInputElement>('#tokval').select();
|
||||
}catch(err){ toast(why(err),true); }
|
||||
};
|
||||
$('#sblock').onchange=async e=>{
|
||||
const blocked=splitWords(e.target.value);
|
||||
const saveKey=async(typesafe_key: string|null)=>{
|
||||
try{
|
||||
await api('/api/me',{method:'PATCH',body:JSON.stringify({typesafe_key})});
|
||||
if(S.me) S.me.typesafe_key_set=!!typesafe_key;
|
||||
$<HTMLInputElement>('#stskey').value='';
|
||||
$<HTMLInputElement>('#stskey').placeholder=typesafe_key?'Saved. Paste a new one to replace it':'Paste your key';
|
||||
$<HTMLButtonElement>('#stsforget').hidden=!typesafe_key;
|
||||
toast(typesafe_key?'Saved':'Forgotten');
|
||||
}catch(err){ toast(why(err),true); }
|
||||
};
|
||||
$<HTMLInputElement>('#stskey').onchange=(e: Event)=>{ const k=(e.target as HTMLInputElement).value.trim(); if(k) saveKey(k); };
|
||||
$<HTMLButtonElement>('#stsforget').onclick=()=>saveKey(null);
|
||||
$<HTMLInputElement>('#sblock').onchange=async (e: Event)=>{
|
||||
const blocked=splitWords((e.target as HTMLInputElement).value);
|
||||
try{
|
||||
await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})});
|
||||
if(S.me) S.me.blocked=blocked;
|
||||
toast('Saved'); await loadFeeds(true); loadEntries();
|
||||
}catch(err){ toast(err.message,true); }
|
||||
}catch(err){ toast(why(err),true); }
|
||||
};
|
||||
}
|
||||
|
||||
/// Your API tokens, each with when it was made and last used, and a button to revoke it.
|
||||
async function drawTokens(){
|
||||
const box=$('#tokens'); if(!box) return;
|
||||
const list=await api('/api/tokens').catch(()=>[]);
|
||||
const list: {id: number, name: string, created: number, last_used: number|null}[]=await api('/api/tokens').catch((): []=>[]);
|
||||
box.innerHTML=list.map(t=>`<div class="tokrow"><span><b>${esc(t.name)}</b>
|
||||
<span class="hint">made ${dateOf(t.created)}, last used ${ago(t.last_used)}</span></span>
|
||||
<button class="btn ico" data-tok="${t.id}" title="Revoke ${esc(t.name)}" aria-label="Revoke ${esc(t.name)}">${ICON.trash}</button></div>`).join('');
|
||||
for(const b of $$('[data-tok]',box)) b.onclick=async()=>{
|
||||
try{ await api(`/api/tokens/${b.dataset.tok}`,{method:'DELETE'}); toast('Revoked'); drawTokens(); }
|
||||
catch(err){ toast(err.message,true); }
|
||||
catch(err){ toast(why(err),true); }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -486,37 +526,37 @@ function chipsHTML(words: string[], input: string){
|
||||
placeholder="${words.length?'':CHIP_HINT}" enterkeyhint="done" autocomplete="off" spellcheck="false">`;
|
||||
}
|
||||
/// The words in a chip box, with whatever is typed and not yet a chip.
|
||||
function chipWords(box){
|
||||
return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($('.chipin',box).value)];
|
||||
function chipWords(box: ParentNode){
|
||||
return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($<HTMLInputElement>('.chipin',box).value)];
|
||||
}
|
||||
function wireChips(box){
|
||||
const input=$('.chipin',box);
|
||||
function wireChips(box: HTMLElement){
|
||||
const input=$<HTMLInputElement>('.chipin',box);
|
||||
// The hint only in an empty box: beside chips it reads as one more.
|
||||
const hint=()=>{ input.placeholder=$$('.chip',box).length?'':CHIP_HINT; };
|
||||
const add=()=>{
|
||||
const have=$$('.chip',box).map(c=>c.dataset.w.toLowerCase());
|
||||
const have=$$('.chip',box).map(c=>c.dataset.w!.toLowerCase());
|
||||
for(const w of splitWords(input.value)){
|
||||
if(have.includes(w.toLowerCase())) continue;
|
||||
input.insertAdjacentHTML('beforebegin',chipHTML(w)); have.push(w.toLowerCase());
|
||||
}
|
||||
input.value=''; hint();
|
||||
};
|
||||
input.onkeydown=e=>{
|
||||
input.onkeydown=(e: KeyboardEvent)=>{
|
||||
if(e.key==='Enter'||e.key===','){ e.preventDefault(); add(); }
|
||||
else if(e.key==='Backspace'&&!input.value){ input.previousElementSibling?.remove(); hint(); }
|
||||
};
|
||||
// Typed and left without Enter is still meant: Save takes it either way.
|
||||
input.onblur=add;
|
||||
box.onclick=e=>{
|
||||
const x=e.target.closest('.chipx');
|
||||
if(x){ x.parentElement.remove(); hint(); }
|
||||
box.onclick=(e: MouseEvent)=>{
|
||||
const x=(e.target as Element).closest('.chipx');
|
||||
if(x){ x.parentElement!.remove(); hint(); }
|
||||
input.focus();
|
||||
};
|
||||
}
|
||||
|
||||
/// Your settings for one feed, under its address (#167). What it says is what will happen, in this feed's numbers: the download limit was a bare
|
||||
/// box whose blank, number and 0 were explained in bold small print.
|
||||
function settingsModal(f, newUrl?: string){
|
||||
function settingsModal(f: Feed, newUrl?: string){
|
||||
const kids=S.feeds.filter(c=>c.group===f.id).length;
|
||||
const isGroup=kids>0;
|
||||
const parent=f.managed?S.feeds.find(p=>p.id===f.group):null;
|
||||
@@ -524,7 +564,7 @@ function settingsModal(f, newUrl?: string){
|
||||
const name=f.title||f.id;
|
||||
// Blank follows the server's default; 0 is every item (#166).
|
||||
let mode=f.max_new_per_check==null?'default':f.max_new_per_check===0?'all':'newest';
|
||||
let newest=f.max_new_per_check>0?f.max_new_per_check:(globalMax||3);
|
||||
let newest=f.max_new_per_check&&f.max_new_per_check>0?f.max_new_per_check:(globalMax||3);
|
||||
const whose=isGroup
|
||||
? `Your settings for the ${plural(kids,'show')} in this ${isPatreon(f)?'Patreon creator':'OPML subscription'}. A show you set on its own keeps its own.`
|
||||
: parent
|
||||
@@ -576,7 +616,7 @@ function settingsModal(f, newUrl?: string){
|
||||
// What only matters while it downloads on its own folds away, out of reach, when it does not.
|
||||
const deps=$('#sdeps'), inner=$('.fdepsin',deps);
|
||||
inner.inert=!f.auto_download;
|
||||
$('#sauto').onchange=e=>{ deps.classList.toggle('off',!e.target.checked); inner.inert=!e.target.checked; };
|
||||
$<HTMLInputElement>('#sauto').onchange=(e: Event)=>{ deps.classList.toggle('off',!(e.target as HTMLInputElement).checked); inner.inert=!(e.target as HTMLInputElement).checked; };
|
||||
|
||||
// The download limit, said as what it does. A number in a box hid that it is a window on the
|
||||
// newest items, not a pace through the back catalogue (#97).
|
||||
@@ -591,7 +631,7 @@ function settingsModal(f, newUrl?: string){
|
||||
? `The server's default: downloads the newest ${globalMax}${each} on its own.${older}`
|
||||
: `The server's default: downloads every item${each}, the whole back catalogue.`;
|
||||
const limit=()=>mode==='all'||(mode==='default'&&!globalMax)?Infinity:mode==='default'?globalMax:newest;
|
||||
let bars=[];
|
||||
let bars: HTMLElement[]=[];
|
||||
const draw=(words: boolean)=>{
|
||||
// The newest items with a file, as hold_back counts them: one whose files a filter all
|
||||
// skipped holds no place.
|
||||
@@ -605,21 +645,21 @@ function settingsModal(f, newUrl?: string){
|
||||
}
|
||||
if(!words) return;
|
||||
$('#swcap').innerHTML=caption();
|
||||
const n=$('#smax');
|
||||
const n=$<HTMLInputElement>('#smax');
|
||||
if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); };
|
||||
};
|
||||
for(const r of $$('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); };
|
||||
for(const r of $$<HTMLInputElement>('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); };
|
||||
// Breathing room only matters with DeadAir on: greyed out, not hidden, so it can be seen beforehand.
|
||||
$('#sdead').onchange=e=>{ $('#sdpause').disabled=!e.target.checked; };
|
||||
const pause=$('#sdpause'), pauseText=()=>{ const s=`${pause.value/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); };
|
||||
$<HTMLInputElement>('#sdead').onchange=(e: Event)=>{ $<HTMLInputElement>('#sdpause').disabled=!(e.target as HTMLInputElement).checked; };
|
||||
const pause=$<HTMLInputElement>('#sdpause'), pauseText=()=>{ const s=`${Number(pause.value)/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); };
|
||||
pause.oninput=pauseText; pauseText();
|
||||
draw(true);
|
||||
// The feed's own newest items, each as tall as it is long, so the strip reads as this show.
|
||||
if(!isGroup) api(`/api/feeds/${encodeURIComponent(f.id)}/entries?limit=40`).then(page=>{
|
||||
const items=page?.entries||[];
|
||||
const items: Entry[]=page?.entries||[];
|
||||
const strip=$('#sepwin'); if(!items.length||!strip) return;
|
||||
const longest=Math.max(1,...items.map(e=>e.duration||0));
|
||||
strip.innerHTML=items.map((e,i)=>{
|
||||
strip.innerHTML=items.map((e: Entry,i: number)=>{
|
||||
const file=e.enclosures.some(x=>x.state!=='skipped');
|
||||
const h=e.duration?Math.round(30+70*Math.sqrt(e.duration/longest)):60;
|
||||
return `<i class="${file?'':'nofile'}" style="--h:${h}%;--n:${i}"></i>`;
|
||||
@@ -634,65 +674,65 @@ function settingsModal(f, newUrl?: string){
|
||||
if(x<=b.getBoundingClientRect().right) break;
|
||||
}
|
||||
newest=Math.max(1,n);
|
||||
if(mode!=='newest'){ mode='newest'; $('[name=swin][value=newest]').checked=true; draw(true); }
|
||||
else { $('#smax').value=String(newest); draw(false); }
|
||||
if(mode!=='newest'){ mode='newest'; $<HTMLInputElement>('[name=swin][value=newest]').checked=true; draw(true); }
|
||||
else { $<HTMLInputElement>('#smax').value=String(newest); draw(false); }
|
||||
};
|
||||
strip.onpointerdown=e=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
|
||||
strip.onpointermove=e=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); };
|
||||
strip.onpointerdown=(e: PointerEvent)=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
|
||||
strip.onpointermove=(e: PointerEvent)=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); };
|
||||
}).catch(()=>{});
|
||||
|
||||
wireChips($('#skw'));
|
||||
if($('#sblock')) wireChips($('#sblock'));
|
||||
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
|
||||
$<HTMLButtonElement>('#scopy').onclick=()=>copyText($<HTMLInputElement>('#surl').value,$<HTMLButtonElement>('#scopy'));
|
||||
// Opened to follow a feed that moved: the new address is what there is to look at.
|
||||
if(newUrl){ $('#surl').scrollIntoView({block:'center'}); $('#surl').focus(); }
|
||||
if(newUrl){ $<HTMLInputElement>('#surl').scrollIntoView({block:'center'}); $<HTMLInputElement>('#surl').focus(); }
|
||||
|
||||
$('#ssave').onclick=async()=>{
|
||||
$<HTMLButtonElement>('#ssave').onclick=async()=>{
|
||||
try{
|
||||
const patch: Record<string, unknown>={
|
||||
keywords:chipWords($('#skw')),
|
||||
max_new_per_check:mode==='default'?null:mode==='all'?0:newest,
|
||||
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked,
|
||||
deadair_pause:Number($('#sdpause').value)};
|
||||
auto_download:$<HTMLInputElement>('#sauto').checked, allow_explicit:$<HTMLInputElement>('#sexp').checked, deadair:$<HTMLInputElement>('#sdead').checked,
|
||||
deadair_pause:Number($<HTMLInputElement>('#sdpause').value)};
|
||||
if($('#sblock')) patch.blocked=chipWords($('#sblock'));
|
||||
// Sent only when it changed: anyone may mark a feed private, but only an admin unmark it.
|
||||
const priv=$('#sprivate');
|
||||
const priv=$<HTMLInputElement>('#sprivate');
|
||||
if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked;
|
||||
// The address is the same for everyone: an admin's to change, for a feed that moved, and the
|
||||
// API refuses it from anyone else. The folder and category are not this dialog's at all.
|
||||
if(admin) patch.url=$('#surl').value.trim();
|
||||
if(admin) patch.url=$<HTMLInputElement>('#surl').value.trim();
|
||||
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
|
||||
closeModal(); toast('Saved. It applies from the next check.');
|
||||
await loadFeeds(true); renderFeed(); loadEntries();
|
||||
}catch(e){ toast(e.message,true); }
|
||||
}catch(e){ toast(why(e),true); }
|
||||
};
|
||||
}
|
||||
|
||||
function downloadLatestModal(f){
|
||||
function downloadLatestModal(f: Feed){
|
||||
openModal(`<h3>Download latest items</h3>
|
||||
<div class="field"><label>How many of the newest undownloaded items?</label>
|
||||
<input type="number" id="dcount" min="1" max="100" value="5">
|
||||
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
<button class="btn ico primary" id="dgo" title="Download" aria-label="Download">${ICON.download}</button></div>`);
|
||||
$('#dgo').onclick=async()=>{
|
||||
const n=Number($('#dcount').value)||5;
|
||||
$<HTMLButtonElement>('#dgo').onclick=async()=>{
|
||||
const n=Number($<HTMLInputElement>('#dcount').value)||5;
|
||||
try{
|
||||
const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`,
|
||||
{method:'POST',body:JSON.stringify({count:n})});
|
||||
closeModal(); toast(`Queued ${r.queued} item${r.queued===1?'':'s'}`);
|
||||
}catch(e){ toast(e.message,true); }
|
||||
}catch(e){ toast(why(e),true); }
|
||||
};
|
||||
}
|
||||
|
||||
function removeFeed(f){
|
||||
function removeFeed(f: Feed){
|
||||
openModal(`<h3>Unsubscribe?</h3>
|
||||
<p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> from your feeds. Anyone else
|
||||
reading it keeps it, along with their own read state.
|
||||
Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p>
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button></div>`);
|
||||
$('#rgo').onclick=async()=>{
|
||||
$<HTMLButtonElement>('#rgo').onclick=async()=>{
|
||||
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
|
||||
closeModal(); toast('Unsubscribed'); S.feed=null;
|
||||
await loadFeeds(); if(!S.feeds.length) renderFeed();
|
||||
@@ -711,11 +751,11 @@ function opmlModal(){
|
||||
<textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
|
||||
<button class="btn ico primary" id="oimp" title="Import: subscribe to every feed in it" aria-label="Import">${ICON.plus}</button></div>`);
|
||||
$('#oimp').onclick=async()=>{
|
||||
$<HTMLButtonElement>('#oimp').onclick=async()=>{
|
||||
// A chosen file is read here and sent as text, so the server never stores it. Clearing
|
||||
// the picker lets go of it on this side too, whether it was refused or imported.
|
||||
const pick=$('#opmlFile'), file=pick.files[0];
|
||||
const xml=file ? await file.text() : $('#opmlText').value;
|
||||
const pick=$<HTMLInputElement>('#opmlFile'), file=pick.files![0];
|
||||
const xml=file ? await file.text() : $<HTMLTextAreaElement>('#opmlText').value;
|
||||
const letGo=()=>{ pick.value=''; };
|
||||
// A quick look before sending anything. The server parses it properly and has the last word.
|
||||
if(!/<opml[\s>]/i.test(xml)){
|
||||
@@ -724,17 +764,21 @@ function opmlModal(){
|
||||
try{
|
||||
const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml})});
|
||||
letGo(); closeModal(); toast(`Subscribed to ${r.added} feed(s)`+(r.already?`, ${r.already} you already had`:'')); loadFeeds(true);
|
||||
}catch(e){ letGo(); toast(e.message,true); }
|
||||
}catch(e){ letGo(); toast(why(e),true); }
|
||||
};
|
||||
}
|
||||
|
||||
// No toast: the spinners on the rows being checked say it (issue #37).
|
||||
async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
||||
$('#scanAll').onclick=scanAll;
|
||||
$('#prefs').onclick=prefsModal;
|
||||
async function scanAll(){
|
||||
spinStart(null);
|
||||
try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
||||
catch(e){ spinEnd(); throw e; }
|
||||
}
|
||||
$<HTMLButtonElement>('#scanAll').onclick=scanAll;
|
||||
$<HTMLButtonElement>('#prefs').onclick=prefsModal;
|
||||
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
||||
// the proxy still vouches for them. /api/me says where, when that is the case.
|
||||
$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
|
||||
$<HTMLButtonElement>('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
|
||||
api('/api/me').then(u=>{
|
||||
S.me=u;
|
||||
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
||||
@@ -746,18 +790,18 @@ api('/api/me').then(u=>{
|
||||
// A browser with no theme of its own takes the one the account kept from before, once.
|
||||
if(u.theme&&!/(^|; )ipx_theme=/.test(document.cookie)) setTheme(u.theme.name,u.theme.mode,true);
|
||||
}).catch(()=>{});
|
||||
$('#feedFilter').oninput=renderFeeds;
|
||||
$<HTMLInputElement>('#feedFilter').oninput=renderFeeds;
|
||||
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
|
||||
// folder. Handled keys stop here, or the player's own Space and arrows would act on them too.
|
||||
$('#feedlist').onkeydown=ev=>{
|
||||
const row=ev.target.closest('[data-id]'); if(!row) return;
|
||||
const id=row.dataset.id;
|
||||
$('#feedlist').onkeydown=(ev: KeyboardEvent)=>{
|
||||
const row=(ev.target as Element).closest<HTMLElement>('[data-id]'); if(!row) return;
|
||||
const id=row.dataset.id!;
|
||||
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
|
||||
else if(row.classList.contains('group')&&
|
||||
else if(row.matches('.group,.sigs')&&
|
||||
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
|
||||
else return;
|
||||
ev.preventDefault(); ev.stopPropagation();
|
||||
};
|
||||
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
||||
$<HTMLButtonElement>('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
||||
$('#scrim').onclick=()=>nav(false);
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* ---------------- live events ---------------- */
|
||||
let sse;
|
||||
let sse: EventSource;
|
||||
/// The feed list and the open list, taken again from the server. The events keep both current,
|
||||
/// and one missed is a count wrong until a reload: reading an item sends none to your other
|
||||
/// pages, so one read on another device stayed unread here, and an iPhone stops the stream while
|
||||
/// the app is in the background, when everything is missed (#168).
|
||||
const catchUp=(()=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
|
||||
const catchUp=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
|
||||
loadFeeds(true).catch(()=>{});
|
||||
if(S.feed) loadEntries().catch(()=>{});
|
||||
if(S.feed) loadEntries(false,true).catch(()=>{});
|
||||
},300); }; })();
|
||||
document.addEventListener('visibilitychange',()=>{ if(!document.hidden) catchUp(); });
|
||||
let dropped=false;
|
||||
@@ -14,10 +14,13 @@ function connect(){
|
||||
sse=new EventSource('/api/events');
|
||||
// Back after a drop: whatever was sent meanwhile went to nobody.
|
||||
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
|
||||
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
||||
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
||||
const soon=(fn: ()=>void,ms=500)=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
||||
// Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219).
|
||||
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(false,true).catch(()=>{}); });
|
||||
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
||||
const mine=id=>S.feeds.some(f=>f.id===id);
|
||||
const mine=(id: string)=>S.feeds.some(f=>f.id===id);
|
||||
// The lists a change made stale, fetched again behind, once a burst of rows has passed (#247).
|
||||
const preloadSoon=soon(preloadLists,2000);
|
||||
sse.onmessage=m=>{
|
||||
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
||||
if(ev.ev==='progress'){
|
||||
@@ -25,7 +28,7 @@ function connect(){
|
||||
// Only the row actually downloading. Without the enclosure id this used to paint
|
||||
// every pending bar at once, so adding a feed looked like it was fetching the lot.
|
||||
const bar=document.querySelector<HTMLElement>(`.dlbar[data-bar="${ev.enclosure}"] i`);
|
||||
if(bar){ bar.style.width=pct+'%'; bar.parentElement.classList.add('live'); }
|
||||
if(bar){ bar.style.width=pct+'%'; bar.parentElement!.classList.add('live'); }
|
||||
$('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`);
|
||||
}
|
||||
else if(ev.ev==='download_done'){
|
||||
@@ -40,13 +43,19 @@ function connect(){
|
||||
if(!mine(ev.feed)) return;
|
||||
if(ev.saved_s>0&&document.querySelector('.dapend')) toast(`DeadAir is done: ${shorter(ev.saved_s)}`);
|
||||
refreshEntries();
|
||||
if(S.sel) loadEntries().then(()=>{ const e=cur(); if(e) showDetail(e); }).catch(()=>{});
|
||||
if(S.sel) loadEntries(false,true).then(()=>{ const e=cur(); if(e) showDetail(e); }).catch(()=>{});
|
||||
}
|
||||
else if(ev.ev==='download_error'){
|
||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||
if(bar) bar.classList.remove('live');
|
||||
toast('Download failed: '+ev.msg,true); refreshEntries();
|
||||
}
|
||||
// Jev picked items out for someone's Signals: theirs are fetched again, and the one open.
|
||||
else if(ev.ev==='signal_hits'){
|
||||
if(!S.signals.length) return;
|
||||
refreshSignals();
|
||||
if(signalOf(S.feed)) refreshEntries();
|
||||
}
|
||||
// A spinner on the feed's row while it is checked, in place of a toast per feed (issue #37).
|
||||
else if(ev.ev==='feed_start') setScanning(ev.feed,true);
|
||||
else if(ev.ev==='feed_skip') setScanning(ev.feed,false);
|
||||
@@ -60,14 +69,16 @@ function connect(){
|
||||
else if(ev.ev==='feed_error') setScanning(ev.feed,false);
|
||||
// The server sends a feed's new row after anything changes it (counts, error, last check),
|
||||
// only to those who subscribe; the page used to reload the whole list after each event.
|
||||
else if(ev.ev==='feed_row') patchFeed(ev.row);
|
||||
else if(ev.ev==='feed_row'){ patchFeed(ev.row); forgetLists(ev.row.id); preloadSoon(); }
|
||||
// Only a scan that checked something: the scheduler scans every minute, due or not, and
|
||||
// every open page reloaded the whole list each time (#103).
|
||||
// Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the
|
||||
// page reloads; reload the list here when a scan synced an OPML if that ever matters.
|
||||
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); if(ev.feeds) refreshEntries(); }
|
||||
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); spinEnd(ev); if(ev.feeds) refreshEntries(); }
|
||||
// The worker's job failed, so the scan someone is waiting on will send no scan_done.
|
||||
else if(ev.ev==='error') spinEnd();
|
||||
};
|
||||
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
|
||||
}
|
||||
connect();
|
||||
loadFeeds();
|
||||
loadFeeds().then(preloadLists).catch(()=>{});
|
||||
|
||||
@@ -3,16 +3,18 @@ function renderFeed(){
|
||||
paintScanning(); // another feed's page may be the one open now
|
||||
const box=$('#content');
|
||||
box.classList.remove('plain');
|
||||
const v=VIEWS[S.feed];
|
||||
const v=VIEWS[S.feed ?? ''];
|
||||
if(v&&v.url) return renderListed(v);
|
||||
if(S.feed===SIGNALS) return renderSignals();
|
||||
const f=v?null:S.feeds.find(x=>x.id===S.feed);
|
||||
$('#tbRemove').disabled=!f;
|
||||
const sg=signalOf(S.feed);
|
||||
$<HTMLButtonElement>('#tbRemove').disabled=!f;
|
||||
syncTools(null);
|
||||
if(!v&&!f){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
|
||||
const name=f?(f.title||f.id):v.title;
|
||||
$('#epSearch').placeholder=`Search ${name}…`;
|
||||
if(!v&&!f&&!sg){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
|
||||
const name=f?(f.title||f.id):sg?sg.name:v.title;
|
||||
$<HTMLInputElement>('#epSearch').placeholder=`Search ${name}…`;
|
||||
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
|
||||
if(kids.length){ box.classList.add('plain'); renderGroup(f,kids); return; }
|
||||
if(kids.length){ box.classList.add('plain'); renderGroup(f!,kids); return; }
|
||||
const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||
box.innerHTML = (f ? `
|
||||
<div class="fhead slim">
|
||||
@@ -36,7 +38,7 @@ function renderFeed(){
|
||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||
</div>
|
||||
</div>` : `
|
||||
</div>` : sg ? signalHead(sg) : `
|
||||
<div class="fhead slim">
|
||||
<div class="art">${v.icon}</div>
|
||||
<div class="meta">
|
||||
@@ -61,7 +63,7 @@ function renderFeed(){
|
||||
if(f) pane.className='one';
|
||||
pane.innerHTML='<div id="list">'+sortHead()+
|
||||
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
|
||||
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort));
|
||||
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort!));
|
||||
box.appendChild(pane);
|
||||
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%');
|
||||
dragSplit(pane);
|
||||
@@ -69,9 +71,10 @@ function renderFeed(){
|
||||
S.sel=null;
|
||||
showDetail(null);
|
||||
|
||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a));
|
||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a!,f):sg?signalAction(b.dataset.a!,sg):allAction(b.dataset.a!));
|
||||
$$('#content .tabs button').forEach(b=>b.onpointerenter=b.ontouchstart=()=>{ if(S.feed) prefetch(S.feed,b.dataset.f!); });
|
||||
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
|
||||
S.filter=b.dataset.f; S.offset=0;
|
||||
S.filter=b.dataset.f!; S.offset=0;
|
||||
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
|
||||
renderFeed(); loadEntries();
|
||||
});
|
||||
@@ -89,7 +92,7 @@ function sortHead(){
|
||||
`${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`;
|
||||
}).join('')+'<span></span></div>';
|
||||
}
|
||||
function sortBy(col){
|
||||
function sortBy(col: string){
|
||||
const first=['published','size','kept'].includes(col)?'desc':'asc';
|
||||
S.sort={col,dir:S.sort.col===col?(S.sort.dir==='asc'?'desc':'asc'):first};
|
||||
try{ localStorage.setItem('ipx.sort',JSON.stringify(S.sort)); }catch{}
|
||||
@@ -99,9 +102,9 @@ function sortBy(col){
|
||||
/// An OPML subscription's page lists the feeds inside it rather than items, but keeps
|
||||
/// every action a normal feed has -- it is still an ordinary feed entry underneath.
|
||||
// A Patreon creator split into its shows is drawn like an OPML, and named for what it is.
|
||||
const isPatreon=f=>/patreon\.com\//.test(f&&f.url||'');
|
||||
const isPatreon=(f: {url: string}|null)=>/patreon\.com\//.test(f&&f.url||'');
|
||||
|
||||
function renderGroup(f,kids){
|
||||
function renderGroup(f: Feed,kids: Feed[]){
|
||||
const unread=kids.reduce((n,c)=>n+c.unread,0);
|
||||
const saved=kids.reduce((n,c)=>n+c.downloaded,0);
|
||||
const gone=kids.filter(c=>c.orphaned).length;
|
||||
@@ -132,10 +135,10 @@ function renderGroup(f,kids){
|
||||
<span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span>
|
||||
</div>
|
||||
<div class="childlist" id="kidlist"></div>`;
|
||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
|
||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a!,f));
|
||||
wireFailBanner($('#content'),f);
|
||||
const draw=()=>{
|
||||
const q=($('#kidSearch').value||'').trim().toLowerCase();
|
||||
const q=($<HTMLInputElement>('#kidSearch').value||'').trim().toLowerCase();
|
||||
const box=$('#kidlist'); box.innerHTML='';
|
||||
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
|
||||
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
|
||||
@@ -154,26 +157,30 @@ function renderGroup(f,kids){
|
||||
box.appendChild(el);
|
||||
}
|
||||
};
|
||||
$('#kidSearch').oninput=draw;
|
||||
$<HTMLInputElement>('#kidSearch').oninput=draw;
|
||||
draw();
|
||||
}
|
||||
|
||||
async function feedAction(a,f){
|
||||
if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
||||
async function feedAction(a: string,f: Feed){
|
||||
if(a==='scan'){
|
||||
spinStart(f.id);
|
||||
try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
||||
catch(e){ spinEnd(); throw e; }
|
||||
}
|
||||
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
||||
if(a==='rm') removeFeed(f);
|
||||
if(a==='pin'){
|
||||
try{
|
||||
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({pinned:!f.pinned})});
|
||||
await loadFeeds(true); renderFeed();
|
||||
}catch(e){ toast(e.message,true); }
|
||||
}catch(e){ toast(why(e),true); }
|
||||
}
|
||||
if(a==='settings') settingsModal(f);
|
||||
if(a==='share') share(f.title||f.id, f.url, $('#content .acts [data-a="share"]'));
|
||||
if(a==='dl') downloadLatestModal(f);
|
||||
}
|
||||
/// All Subscriptions' own buttons: a feed's, across every feed you read.
|
||||
async function allAction(a){
|
||||
async function allAction(a: string){
|
||||
if(a==='scanall') return scanAll();
|
||||
if(a==='readall'){
|
||||
const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0);
|
||||
@@ -183,7 +190,7 @@ async function allAction(a){
|
||||
try{
|
||||
const r=await api('/api/read-all',{method:'POST'});
|
||||
toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries();
|
||||
}catch(e){ toast(e.message,true); }
|
||||
}catch(e){ toast(why(e),true); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
/// One feed's row from the server, in place of the old one (or added, for a feed an OPML just
|
||||
/// listed). A scan sends dozens; the list is redrawn once a frame, not once each.
|
||||
let rowsQueued=0;
|
||||
function patchFeed(row){
|
||||
function patchFeed(row: Feed){
|
||||
const i=S.feeds.findIndex(f=>f.id===row.id);
|
||||
if(i<0) S.feeds.push(row); else S.feeds[i]=row;
|
||||
if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); });
|
||||
}
|
||||
|
||||
async function loadFeeds(keepSel?: boolean){
|
||||
S.feeds = await api('/api/feeds');
|
||||
// A Signal list that fails leaves the feeds to show.
|
||||
[S.feeds, S.signals] = await Promise.all([api('/api/feeds'), api('/api/signals').catch((): Signal[]=>[])]);
|
||||
// Only for a hint in a feed's settings, so once, on the page's first load, not on every reload
|
||||
// of the list (#103).
|
||||
if(!keepSel) api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
|
||||
@@ -18,59 +19,76 @@ async function loadFeeds(keepSel?: boolean){
|
||||
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
|
||||
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
|
||||
if(!keepSel && S.feeds.length){
|
||||
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
|
||||
selectFeed(known ? S.feed : ':all');
|
||||
const known = S.feed && (VIEWS[S.feed] || S.feed===SIGNALS || signalOf(S.feed) || S.feeds.some(f=>f.id===S.feed));
|
||||
selectFeed(known ? S.feed! : ':all');
|
||||
}
|
||||
}
|
||||
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
|
||||
// server's alphabetical order still holds within each half.
|
||||
const unreadFirst=(a,b)=>Number(b.unread>0)-Number(a.unread>0);
|
||||
const unreadFirst=(a: Feed,b: Feed)=>Number(b.unread>0)-Number(a.unread>0);
|
||||
// The original's source list opened with these, above the feeds. They are places, not feeds:
|
||||
// an id starting with ':' can never be a feed's, since feed ids are slugs.
|
||||
const VIEWS={
|
||||
const VIEWS: Record<string,{title: string, icon: string, url?: string, blurb?: string}>={
|
||||
// The Directory holds the most subscribed feeds too, which had a place of their own, Popular (#124).
|
||||
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory'},
|
||||
':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50',
|
||||
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
|
||||
':all':{title:'All Subscriptions',icon:ICON.all},
|
||||
};
|
||||
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder
|
||||
/// holding one, carry a spinner.
|
||||
const scanning=new Set<string>();
|
||||
/// Feeds being checked right now, from the event stream, and since when: their rows, and the row
|
||||
/// of a folder holding one, carry a spinner.
|
||||
const scanning=new Map<string, number>();
|
||||
/// A row's spinner takes the place of its unread count, so it waits this long: most feeds are
|
||||
/// checked in a few milliseconds, and each count blinked out and back as its feed went by.
|
||||
const SPIN_AFTER=300;
|
||||
function setScanning(id: string, on: boolean){
|
||||
if(on) scanning.add(id); else scanning.delete(id);
|
||||
if(on){ scanning.set(id,Date.now()); setTimeout(paintScanning,SPIN_AFTER); } else scanning.delete(id);
|
||||
paintScanning();
|
||||
}
|
||||
function paintScanning(){
|
||||
const slow=(id: string)=>scanning.has(id)&&Date.now()-scanning.get(id)!>=SPIN_AFTER;
|
||||
for(const row of $$('#feedlist .feed')){
|
||||
const id=row.dataset.id;
|
||||
const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
|
||||
const id=row.dataset.id!;
|
||||
const on=slow(id)||S.feeds.some(c=>c.group===id&&slow(c.id));
|
||||
row.classList.toggle('scanning',on);
|
||||
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
|
||||
}
|
||||
// The refresh buttons turn while what they check is being checked (#78). On <body>, because
|
||||
// a feed's page is redrawn as its items come in and would take a class on the button with it.
|
||||
const busy=id=>scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
|
||||
document.body.classList.toggle('scan-this',busy(S.feed));
|
||||
document.body.classList.toggle('scan-any',S.feeds.some(f=>scanning.has(f.id)));
|
||||
// A feed's button turns on its own page only, wherever the person has gone since pressing it.
|
||||
document.body.classList.toggle('scan-this',spinFeed!==null&&spinFeed===S.feed);
|
||||
}
|
||||
/// The refresh buttons turn from the press until that scan's scan_done (#78, #233). On <body>,
|
||||
/// because a feed's page is redrawn as its items come in and would take a class on the button
|
||||
/// with it. Following the feeds being checked instead, the icon snapped back upright in the gap
|
||||
/// between one feed and the next, many times a second (#231).
|
||||
let spinFeed: string|null=null;
|
||||
function spinStart(feed: string|null){
|
||||
if(feed===null) document.body.classList.add('scan-any');
|
||||
else { spinFeed=feed; paintScanning(); }
|
||||
}
|
||||
/// `only` and `forced` from scan_done: a scheduled scan, or one limited to another feed, leaves
|
||||
/// a pressed button turning, since the scan it asked for is still queued behind it. Nothing
|
||||
/// (an error ended the job) stops them all.
|
||||
function spinEnd(done?: {only: string|null, forced: boolean}){
|
||||
if(!done||done.forced&&done.only==null) document.body.classList.remove('scan-any');
|
||||
if(!done||done.forced&&done.only===spinFeed){ spinFeed=null; paintScanning(); }
|
||||
}
|
||||
|
||||
function renderFeeds(){
|
||||
const q=$('#feedFilter').value.trim().toLowerCase();
|
||||
const q=$<HTMLInputElement>('#feedFilter').value.trim().toLowerCase();
|
||||
const list=$('#feedlist'); const top=list.scrollTop;
|
||||
// Every row is replaced, so put the keyboard back on the row, or the triangle, it was on.
|
||||
const a=document.activeElement, was=a&&a.closest&&a.closest<HTMLElement>('#feedlist [data-id]');
|
||||
const back=was&&([was.dataset.id,a.classList.contains('chev')] as [string, boolean]);
|
||||
const done=()=>{
|
||||
list.scrollTop=top;
|
||||
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
|
||||
if(row) (back[1]&&$('.chev',row)||row).focus();
|
||||
const row=back&&list.querySelector<HTMLElement>(`[data-id="${CSS.escape(back[0])}"]`);
|
||||
if(row) (back[1]&&$('.chev',row)||row as HTMLElement).focus();
|
||||
};
|
||||
// The images already drawn, to go into the rows that replace theirs. Made anew, every icon in
|
||||
// the list went blank and loaded again, once a frame through a check of every feed, as each
|
||||
// feed's new row came in (#134). Matched by their markup, so a changed one is made anew.
|
||||
const drawn=new Map<string,HTMLImageElement[]>();
|
||||
for(const img of $$('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k).push(img); }
|
||||
for(const img of $$<HTMLImageElement>('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k)!.push(img); }
|
||||
list.innerHTML='';
|
||||
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
|
||||
const places=document.createElement('div');
|
||||
@@ -82,18 +100,20 @@ function renderFeeds(){
|
||||
el.innerHTML=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all'
|
||||
?`<span class="badge${unreadAll?'':' zero'}" title="${unreadAll} unread">${unreadAll>999?'999+':unreadAll}</span>`:'');
|
||||
el.onclick=()=>{ selectFeed(id); nav(false); };
|
||||
el.onpointerenter=el.ontouchstart=()=>prefetch(id);
|
||||
places.appendChild(el);
|
||||
}
|
||||
list.appendChild(places);
|
||||
signalFolder(list,q);
|
||||
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
|
||||
if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return done(); }
|
||||
|
||||
// Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds
|
||||
// go first: a folder with its feeds under it, a feed from inside one lifted out of it.
|
||||
const byId=Object.fromEntries(shown.map(f=>[f.id,f]));
|
||||
const inside=f=>shown.filter(c=>c.group===f.id&&!c.pinned);
|
||||
const inside=(f: Feed)=>shown.filter(c=>c.group===f.id&&!c.pinned);
|
||||
const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group]));
|
||||
const order=[];
|
||||
const order: [Feed, number][]=[];
|
||||
for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){
|
||||
order.push([f,0]);
|
||||
// A subscription can hold dozens of feeds, so a folder starts closed. Searching
|
||||
@@ -110,14 +130,14 @@ function renderFeeds(){
|
||||
// A subscription holds no entries itself, so its counts are the sum of what is inside --
|
||||
// taken from every feed it holds, not just the ones a filter left showing.
|
||||
const mine=S.feeds.filter(c=>c.group===f.id);
|
||||
const sum=k=>mine.reduce((n,c)=>n+(c[k]||0),0);
|
||||
const sum=(k: 'unread'|'entries'|'downloaded')=>mine.reduce((n,c)=>n+(c[k]||0),0);
|
||||
const [unread,eps,saved]=mine.length
|
||||
? [sum('unread'),sum('entries'),sum('downloaded')]
|
||||
: [f.unread,f.entries,f.downloaded];
|
||||
// A group's own row has no error of its own worth mentioning if the OPML itself
|
||||
// reads fine; it is failing when any feed inside it is. The feed's own page says what
|
||||
// went wrong (failBannerHTML); the list only has to make it findable.
|
||||
const bad=c=>c.failing?.reason||c.last_error;
|
||||
const bad=(c: Feed)=>c.failing?.reason||c.last_error;
|
||||
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
|
||||
const nbad=mine.filter(bad).length;
|
||||
const el=document.createElement('div');
|
||||
@@ -143,15 +163,17 @@ function renderFeeds(){
|
||||
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
||||
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
||||
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
||||
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||
// Fetched as the pointer or a finger reaches it, so the click finds it here (#247).
|
||||
el.onpointerenter=el.ontouchstart=()=>prefetch(f.id);
|
||||
if(kids) $('.chev',el).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||
for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); }
|
||||
list.appendChild(el);
|
||||
}
|
||||
paintScanning();
|
||||
done();
|
||||
}
|
||||
function selectFeed(id){
|
||||
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
|
||||
function selectFeed(id: string){
|
||||
S.feed=id; S.offset=0; S.sel=null; S.q=''; $<HTMLInputElement>('#epSearch').value='';
|
||||
try{ localStorage.setItem('ipx.feed',id); }catch{}
|
||||
renderFeeds(); renderFeed(); loadEntries();
|
||||
}
|
||||
@@ -159,15 +181,15 @@ function selectFeed(id){
|
||||
/// A failing feed's error, in plain words with something to do about it, once `failing` is
|
||||
/// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in
|
||||
/// src/feed.rs). Anything else still shows the raw error, as before.
|
||||
function failBannerHTML(f){
|
||||
function failBannerHTML(f: Feed){
|
||||
if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)}
|
||||
<button type="button" class="btn tiny" data-ffail="unsub">Unsubscribe</button>${
|
||||
f.failing.new_url?` <button type="button" class="btn tiny" data-ffail="newurl">Use the new address</button>`:''}</div>`;
|
||||
if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`;
|
||||
return '';
|
||||
}
|
||||
function wireFailBanner(box,f){
|
||||
function wireFailBanner(box: ParentNode,f: Feed){
|
||||
const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f);
|
||||
const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing.new_url);
|
||||
const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing?.new_url??undefined);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ document.addEventListener('touchstart', ev => {
|
||||
// back button is never swallowed with it.
|
||||
if(t.clientX < 14 && narrow() && ev.cancelable) ev.preventDefault();
|
||||
}
|
||||
else if(list && list.scrollTop <= 0 && !VIEWS[S.feed]?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0};
|
||||
else if(list && list.scrollTop <= 0 && !VIEWS[S.feed ?? '']?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0};
|
||||
}, {passive: false});
|
||||
|
||||
document.addEventListener('touchmove', ev => {
|
||||
|
||||
209
web/src/items.ts
209
web/src/items.ts
@@ -1,27 +1,114 @@
|
||||
/* ---------------- items ---------------- */
|
||||
let loadSeq=0;
|
||||
async function loadEntries(append?: boolean){
|
||||
// The Directory lists feeds, and Currently Listening draws its own episodes.
|
||||
if(!S.feed || VIEWS[S.feed]?.url) return;
|
||||
const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
|
||||
type ListPage={total: number, entries: Entry[]};
|
||||
/// The first page of each list opened or fetched ahead, by what it shows, so a list seen before is
|
||||
/// drawn the moment it is asked for and refreshed behind it (#247). The newest few dozen: a page is
|
||||
/// 50 items, about 100 KB as JSON.
|
||||
const lists=new Map<string,ListPage>();
|
||||
const LISTS_KEPT=40;
|
||||
/// Lists on their way, so a click on one already being fetched ahead waits for that answer.
|
||||
const fetching=new Map<string,Promise<ListPage>>();
|
||||
const listKey=(feed: string, filter=S.filter)=>[feed,filter,S.sort.col,S.sort.dir,S.q].join('\n');
|
||||
function listUrl(feed: string, filter=S.filter, offset=0){
|
||||
const p=new URLSearchParams({offset:String(offset),limit:String(LIMIT),filter,sort:S.sort.col,dir:S.sort.dir});
|
||||
if(S.q) p.set('q',S.q);
|
||||
const asked=performance.now(), seq=++loadSeq;
|
||||
const r=await api(S.feed===':all' ? `/api/entries?${p}`
|
||||
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
|
||||
const sg=signalId(feed);
|
||||
return feed===':all' ? `/api/entries?${p}`
|
||||
: sg!==null ? `/api/signals/${sg}/entries?${p}`
|
||||
: `/api/feeds/${encodeURIComponent(feed)}/entries?${p}`;
|
||||
}
|
||||
function keepList(key: string, r: ListPage){
|
||||
lists.delete(key); lists.set(key,r);
|
||||
if(lists.size>LISTS_KEPT) lists.delete(lists.keys().next().value!);
|
||||
}
|
||||
/// Fetches a list's first page ahead of a click: when the page has loaded, and when the pointer
|
||||
/// reaches a feed or a filter. Not one kept already or on its way.
|
||||
function prefetch(feed: string, filter=S.filter){
|
||||
// Currently Listening and the Directory draw from their own address; a folder's page lists its
|
||||
// feeds, not items.
|
||||
const url=VIEWS[feed]?.url;
|
||||
if(url) return prefetchPage(url);
|
||||
if(S.feeds.some(c=>c.group===feed)) return;
|
||||
const key=listKey(feed,filter);
|
||||
if(lists.has(key)||fetching.has(key)) return;
|
||||
const got: Promise<ListPage>=api(listUrl(feed,filter));
|
||||
fetching.set(key,got);
|
||||
got.then(r=>keepList(key,r),()=>{}).finally(()=>fetching.delete(key));
|
||||
}
|
||||
/// Other answers a view draws from, kept by address as the lists are (#248): Currently Listening
|
||||
/// and the Directory's catalogue. A request already on its way is shared, not sent twice.
|
||||
const pages=new Map<string,any>();
|
||||
const pageFetching=new Map<string,Promise<any>>();
|
||||
function fetchPage(url: string): Promise<any>{
|
||||
const on=pageFetching.get(url); if(on) return on;
|
||||
const got=api(url).then(r=>{ pages.delete(url); pages.set(url,r); if(pages.size>LISTS_KEPT) pages.delete(pages.keys().next().value!); return r; });
|
||||
pageFetching.set(url,got);
|
||||
got.finally(()=>pageFetching.delete(url)).catch(()=>{});
|
||||
return got;
|
||||
}
|
||||
const prefetchPage=(url: string)=>{ if(!pages.has(url)) fetchPage(url).catch(()=>{}); };
|
||||
|
||||
/// A feed changed (a scan, a read elsewhere): its kept lists, and every one of All Subscriptions,
|
||||
/// are fetched again when next wanted.
|
||||
function forgetLists(feed: string){
|
||||
// A Signal holds items of any feed.
|
||||
for(const k of [...lists.keys()]) if(k.startsWith(feed+'\n')||k.startsWith(':all\n')||k.startsWith(':signal:')) lists.delete(k);
|
||||
}
|
||||
/// What is likely to be opened next, fetched two at a time once the page is idle: All Subscriptions
|
||||
/// and the feeds with something unread, most first.
|
||||
function preloadLists(){
|
||||
const want=[':all',':listening',':directory',...[...S.feeds].filter(f=>f.unread>0&&!f.group).sort((a,b)=>b.unread-a.unread).slice(0,20).map(f=>f.id)];
|
||||
const next=()=>{
|
||||
const f=want.shift(); if(!f) return;
|
||||
prefetch(f);
|
||||
const url=VIEWS[f]?.url, on=url ? pageFetching.get(url) : fetching.get(listKey(f));
|
||||
// On to the next when this one is in, failed or not: a failure here is nobody's to see.
|
||||
if(on) on.then(next,next); else next();
|
||||
};
|
||||
const go=()=>{ next(); next(); };
|
||||
'requestIdleCallback' in window ? (window as any).requestIdleCallback(go,{timeout:2000}) : setTimeout(go,300);
|
||||
}
|
||||
|
||||
/// `quiet` for a refresh nobody asked for (a live update): not a wait anyone sees, so not timed.
|
||||
async function loadEntries(append?: boolean, quiet?: boolean){
|
||||
// The Directory lists feeds, and Currently Listening draws its own episodes.
|
||||
if(!S.feed || VIEWS[S.feed]?.url || S.feed===SIGNALS) return;
|
||||
const feed=S.feed, key=listKey(feed), asked=performance.now(), seq=++loadSeq;
|
||||
// From asking to the list drawn: what someone waits for after a click, under that click's trace
|
||||
// (#246). Once, when the list first shows: from what is kept, at once.
|
||||
let timed=quiet;
|
||||
const shown=()=>{ if(!timed){ timed=true; report('list_shown',{ms:Math.round(performance.now()-asked),feed,filter:S.filter}); } };
|
||||
const kept=append ? undefined : lists.get(key);
|
||||
let spin=0;
|
||||
if(kept){ showList(kept,false,asked); shown(); }
|
||||
// Nothing to show yet: a spinner, if the answer is not back in a blink.
|
||||
else if(!append&&!quiet) spin=setTimeout(()=>{ if(seq===loadSeq) listLoading(); },120);
|
||||
let r: ListPage;
|
||||
try{
|
||||
r=await (!append&&!kept&&fetching.get(key) || api(listUrl(feed,S.filter,S.offset)));
|
||||
}finally{ clearTimeout(spin); }
|
||||
// Only the latest list counts: marking everything read reloads the All tab, and clicking
|
||||
// Unread straight after could have that answer land last and replace the Unread one.
|
||||
if(seq!==loadSeq) return;
|
||||
if(!append) keepList(key,r);
|
||||
// The kept one was this already: drawn again it only flickers.
|
||||
if(kept&&JSON.stringify(kept)===JSON.stringify(r)) return;
|
||||
showList(r,!!append,asked);
|
||||
shown();
|
||||
}
|
||||
/// A list's page into S and onto the screen.
|
||||
function showList(r: ListPage, append: boolean, asked: number){
|
||||
S.total=r.total;
|
||||
for(const e of r.entries){
|
||||
const w=readWrites.get(readKey(e));
|
||||
if(w && !(w.done<asked)) e.read=w.read;
|
||||
if(w && !(w.done!==undefined&&w.done<asked)) e.read=w.read;
|
||||
}
|
||||
let entries = append ? S.entries.concat(r.entries) : r.entries;
|
||||
// A background scan finishing refreshes the list from the server, which -- on the Unread
|
||||
// tab -- would drop the item you have open the moment reading it took it off the filter.
|
||||
// Keep it until you pick a different one; the next refresh after that no longer protects it.
|
||||
// The items turned past on the way to it are kept too, so a swipe back still finds them.
|
||||
const kept=e=>e.guid===S.sel||turned.has(e.guid);
|
||||
const kept=(e: Entry)=>e.guid===S.sel||turned.has(e.guid);
|
||||
if(S.filter==='unread') entries=entries.filter(e=>!e.read||kept(e));
|
||||
if(!append && S.sel){
|
||||
const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>n.guid===e.guid));
|
||||
@@ -30,17 +117,24 @@ async function loadEntries(append?: boolean){
|
||||
S.entries = entries;
|
||||
renderEntries();
|
||||
}
|
||||
/// A list being waited for: said with a spinner, so the click is seen to have been heard.
|
||||
function listLoading(box=$('#eps')){
|
||||
if(!box) return;
|
||||
box.innerHTML='<p class="listload" role="status">Loading…</p>';
|
||||
}
|
||||
function renderEntries(){
|
||||
const box=$('#eps'); if(!box) return;
|
||||
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed];
|
||||
const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? ''], sg=signalOf(S.feed);
|
||||
const unread=f?f.unread:sg?sg.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||
$('#count').textContent=
|
||||
`${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
|
||||
`${f?(f.title||f.id):sg?sg.name:v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
|
||||
const pane=$('#list'), top=pane?pane.scrollTop:0;
|
||||
box.innerHTML='';
|
||||
if(!S.entries.length){
|
||||
// Said only for a search, where the words typed are why; an empty feed or tab speaks for itself.
|
||||
if(S.q) box.innerHTML='<p class="empty">Nothing matches that search.</p>';
|
||||
// A new Signal is empty while Jev reads, which would otherwise look broken.
|
||||
else if(sg&&S.filter==='all') box.innerHTML='<p class="empty">Nothing has come through yet. New items are read as your feeds are checked.</p>';
|
||||
return;
|
||||
}
|
||||
for(const e of S.entries) box.appendChild(epEl(e));
|
||||
@@ -53,7 +147,7 @@ function renderEntries(){
|
||||
box.appendChild(b);
|
||||
}
|
||||
}
|
||||
function epEl(e){
|
||||
function epEl(e: Entry){
|
||||
// An item may carry several files. The row summarises the one you would act on --
|
||||
// the playable one, else anything already downloaded, else the first -- and says how
|
||||
// many others there are; the pane below lists them all.
|
||||
@@ -78,7 +172,7 @@ function epEl(e){
|
||||
num&&`<span>${num}</span>`,
|
||||
left&&`<span>${left}</span>`,
|
||||
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
|
||||
enc&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`,
|
||||
enc&&enc.deadair&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`,
|
||||
enc&&enc.deadair_pending&&DEAD_PENDING,
|
||||
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`,
|
||||
].filter(Boolean).join('<span class="dot"></span>')}</div>
|
||||
@@ -97,13 +191,13 @@ function epEl(e){
|
||||
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
|
||||
</div>`;
|
||||
el.onclick=()=>selectEntry(e);
|
||||
$$('button[data-a]',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
|
||||
$$('button[data-a]',el).forEach(b=>b.onclick=(ev: Event)=>{ev.stopPropagation();epAction(b.dataset.a!,e,el)});
|
||||
return el;
|
||||
}
|
||||
/// Whether the browser can play it. Having a file is not the same as being playable:
|
||||
/// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG
|
||||
/// is just a broken player.
|
||||
function isPlayable(x){
|
||||
function isPlayable(x: Enclosure|undefined){
|
||||
if(!x || !x.path) return false;
|
||||
const m=(x.mime||'').toLowerCase();
|
||||
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
|
||||
@@ -114,7 +208,7 @@ function isPlayable(x){
|
||||
}
|
||||
|
||||
/// What an enclosure is, for a row that is not media: "image", "pdf", "document".
|
||||
function kindOf(enc){
|
||||
function kindOf(enc: {mime?: string|null, url?: string}){
|
||||
const m=(enc.mime||'').toLowerCase();
|
||||
if(m.startsWith('image/')) return 'image';
|
||||
if(m.startsWith('video/')) return 'video';
|
||||
@@ -128,9 +222,9 @@ function kindOf(enc){
|
||||
/// What a file is, as one icon coloured by whether it is here: green once downloaded, red when
|
||||
/// the download failed, plain otherwise, so a file waiting and one deleted read alike. One icon
|
||||
/// either way keeps the column lined up; the words are in its tooltip.
|
||||
function kindIcon(enc){
|
||||
function kindIcon(enc: Enclosure){
|
||||
const k=kindOf(enc);
|
||||
const i={audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent}[k]||ICON.file;
|
||||
const i=(<Record<string,string>>{audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent})[k]||ICON.file;
|
||||
const [cls,label]=enc.path ? [' here',`${k}, downloaded`]
|
||||
: enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`]
|
||||
: ['',k];
|
||||
@@ -138,7 +232,7 @@ function kindIcon(enc){
|
||||
}
|
||||
|
||||
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
|
||||
const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
|
||||
const feedName=(id: string|null|undefined): string=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):(id??''); };
|
||||
|
||||
/// What an item is called. Its title, or for one published without (RSS 2.0 makes it optional,
|
||||
/// and Scripting News titles almost none of its posts) the opening of its text, then its file's
|
||||
@@ -147,24 +241,24 @@ const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id
|
||||
/// Feed HTML as plain words. An inert document: nothing in it loads or runs, where a detached
|
||||
/// element fetches its images. A line break or paragraph's end is a space, or "2010)<br>Recorded"
|
||||
/// read "2010)Recorded".
|
||||
const plainText=(html?: string)=>html
|
||||
const plainText=(html?: string|null)=>html
|
||||
? (new DOMParser().parseFromString(html.replace(/<(br|\/p|\/div|\/li|\/h\d)\b[^>]*>/gi,' $&'),'text/html').body.textContent||'')
|
||||
.replace(/\s+/g,' ').trim() : '';
|
||||
function entryName(e): {text: string, derived: boolean}{
|
||||
function entryName(e: Partial<Entry>): {text: string, derived: boolean}{
|
||||
if(e.title&&e.title.trim()) return {text:e.title, derived:false};
|
||||
const words=plainText(e.description);
|
||||
if(words){
|
||||
const cut=words.length>120 ? words.slice(0,120).replace(/\s+\S*$/,'')+'…' : words;
|
||||
return {text:cut, derived:true};
|
||||
}
|
||||
const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop().replace(/\.[a-z0-9]{1,5}$/i,'');
|
||||
const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop()!.replace(/\.[a-z0-9]{1,5}$/i,'');
|
||||
if(file){ try{ return {text:decodeURIComponent(file), derived:true}; }catch{ return {text:file, derived:true}; } }
|
||||
return {text:[feedName(e.feed_id), dateOf(e.published)].filter(Boolean).join(', '), derived:true};
|
||||
return {text:[feedName(e.feed_id), dateOf(e.published??null)].filter(Boolean).join(', '), derived:true};
|
||||
}
|
||||
|
||||
/// Selecting an item shows it in the pane below, rather than expanding the row.
|
||||
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
|
||||
function swapRow(e){
|
||||
function swapRow(e: Entry){
|
||||
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
|
||||
if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); }
|
||||
}
|
||||
@@ -172,19 +266,21 @@ function swapRow(e){
|
||||
/// Read and unread as this page last set them, and when the server had it. A list asked for
|
||||
/// before then answers with the old state: it put the dot back on an item just read, until
|
||||
/// the next refresh took it off again (loadEntries).
|
||||
const readWrites=new Map();
|
||||
const readKey=e=>e.feed_id+'\n'+e.guid;
|
||||
function setRead(e,read){
|
||||
const readWrites=new Map<string, {read: boolean, done?: number}>();
|
||||
const readKey=(e: Entry)=>e.feed_id+'\n'+e.guid;
|
||||
function setRead(e: Entry,read: boolean){
|
||||
e.read=read;
|
||||
// Kept lists of its feed hold their own copy of it, still as it was.
|
||||
forgetLists(e.feed_id);
|
||||
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
|
||||
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
|
||||
{method:'POST',body:JSON.stringify({read})})
|
||||
.then(row=>{ w.done=performance.now(); if(row) patchFeed(row); });
|
||||
.then(row=>{ w.done=performance.now(); if(row) patchFeed(row); if(S.signals.length) refreshSignals(); });
|
||||
}
|
||||
|
||||
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
|
||||
/// reloaded, so an item does not vanish from under the pointer on the Unread tab.
|
||||
function markRead(e){
|
||||
function markRead(e: Entry){
|
||||
if(e.read) return;
|
||||
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
|
||||
}
|
||||
@@ -193,7 +289,7 @@ function markRead(e){
|
||||
/// in the list until the reader closes or another item is picked from the list. Dropped as each
|
||||
/// was left, a swipe back had nothing to go back to: the item just read was already gone.
|
||||
const turned=new Set<string>();
|
||||
function dropTurned(keep){
|
||||
function dropTurned(keep: string|null){
|
||||
const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep);
|
||||
turned.clear();
|
||||
if(!gone.length) return;
|
||||
@@ -201,7 +297,7 @@ function dropTurned(keep){
|
||||
for(const x of gone) $(`#eps .ep[data-guid="${CSS.escape(x.guid)}"]`)?.remove();
|
||||
}
|
||||
|
||||
function selectEntry(e,turning=false){
|
||||
function selectEntry(e: Entry,turning=false){
|
||||
// On the Unread tab the item you were reading goes as you move on, not whenever a refresh
|
||||
// next happens to come along, which left a few read ones in the list for a while.
|
||||
if(S.filter==='unread' && S.sel && S.sel!==e.guid){
|
||||
@@ -217,10 +313,10 @@ function selectEntry(e,turning=false){
|
||||
}
|
||||
|
||||
/// Drag the divider between the item list and the item text.
|
||||
function dragSplit(pane){
|
||||
function dragSplit(pane: HTMLElement){
|
||||
const grab=$('#grab',pane);
|
||||
if(!grab) return;
|
||||
const move=ev=>{
|
||||
const move=(ev: MouseEvent)=>{
|
||||
const box=pane.getBoundingClientRect();
|
||||
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
|
||||
pane.style.setProperty('--listh',pct.toFixed(1)+'%');
|
||||
@@ -231,7 +327,7 @@ function dragSplit(pane){
|
||||
document.body.style.userSelect='';
|
||||
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
|
||||
};
|
||||
grab.onmousedown=ev=>{
|
||||
grab.onmousedown=(ev: MouseEvent)=>{
|
||||
ev.preventDefault();
|
||||
document.body.style.userSelect='none';
|
||||
document.addEventListener('mousemove',move);
|
||||
@@ -243,24 +339,24 @@ function dragSplit(pane){
|
||||
// ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick
|
||||
// the first. Key rows by feed as well if that ever happens.
|
||||
const cur=()=>S.entries.find(x=>x.guid===S.sel);
|
||||
function syncTools(e){
|
||||
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
||||
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
|
||||
function syncTools(e: Entry|null){
|
||||
$<HTMLButtonElement>('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
||||
$<HTMLButtonElement>('#tbRead').disabled=$<HTMLButtonElement>('#tbFlag').disabled=!e;
|
||||
// The same icons as the item's own buttons beside its title, so the two never disagree. Each
|
||||
// shows what is, as the pin always did: read showed what a click would do, so the two side by
|
||||
// side said opposite things (#74). The shape carries it, not the colour.
|
||||
$('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
|
||||
$('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
|
||||
$('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
|
||||
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
|
||||
$<HTMLButtonElement>('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
|
||||
$<HTMLButtonElement>('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
|
||||
$<HTMLButtonElement>('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
|
||||
$<HTMLButtonElement>('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
|
||||
if(e){
|
||||
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
||||
$('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
|
||||
$<HTMLButtonElement>('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
||||
$<HTMLButtonElement>('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
|
||||
}
|
||||
}
|
||||
|
||||
/// The item's text in the pane below the list, and its files in the pane beside it.
|
||||
function showDetail(e){
|
||||
function showDetail(e: Entry|null){
|
||||
const box=$('#detail'), files=$('#files'); if(!box) return;
|
||||
// A pane that can only say "No files" gives its width to the list instead.
|
||||
if(files) files.hidden=!(e&&e.enclosures.length);
|
||||
@@ -278,15 +374,15 @@ function showDetail(e){
|
||||
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||
box.innerHTML=detailHtml(e);
|
||||
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
||||
$('#dback').onclick=()=>showDetail(null);
|
||||
$<HTMLButtonElement>('#dback').onclick=()=>showDetail(null);
|
||||
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
|
||||
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
|
||||
b.onclick=()=>epAction(b.dataset.a!,e,null,b.dataset.enc?Number(b.dataset.enc):undefined));
|
||||
syncPlayButtons();
|
||||
}
|
||||
|
||||
/// What the reader shows for an item. Its own function because a phone also draws the next or
|
||||
/// previous item beside the reader while it is swiped (gestures.ts).
|
||||
function detailHtml(e){
|
||||
function detailHtml(e: Entry){
|
||||
const encs=e.enclosures.map(encBox).join('');
|
||||
// A phone has no room for the files pane, so the files go above the text there instead.
|
||||
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
|
||||
@@ -317,7 +413,7 @@ function detailHtml(e){
|
||||
}
|
||||
|
||||
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
||||
function encBox(x){
|
||||
function encBox(x: Enclosure){
|
||||
const size=x.length?mb(x.length):'';
|
||||
// One file serves everyone reading the feed, so deleting is not a private act.
|
||||
const f=S.feeds.find(y=>y.id===x.feed_id);
|
||||
@@ -328,8 +424,9 @@ function encBox(x){
|
||||
: 'Delete file';
|
||||
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
|
||||
// Played without its dead air, so saved without it too (#186).
|
||||
const saveLabel=x.deadair>0?'Save to this computer, without its dead air':'Save to this computer';
|
||||
const saveBtn=`<a class="btn ico" href="/media/${x.id}${x.deadair>0?'/deadair':''}" download title="${saveLabel}" aria-label="${saveLabel}">${ICON.save}</a>`;
|
||||
const dead=(x.deadair??0)>0;
|
||||
const saveLabel=dead?'Save to this computer, without its dead air':'Save to this computer';
|
||||
const saveBtn=`<a class="btn ico" href="/media/${x.id}${dead?'/deadair':''}" download title="${saveLabel}" aria-label="${saveLabel}">${ICON.save}</a>`;
|
||||
// The publisher's address, not this server's copy, which only someone signed in here can open.
|
||||
const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`;
|
||||
if(x.path && !isPlayable(x)){
|
||||
@@ -369,14 +466,14 @@ function encBox(x){
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function epAction(a: string, e, el, encId?: number){
|
||||
async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: number){
|
||||
// Swap the row in place and refresh the text below when it is the one being read.
|
||||
const redraw=()=>{ swapRow(e); if(!el || S.sel===e.guid) showDetail(e); };
|
||||
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
|
||||
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
||||
try{
|
||||
if(a==='play') play(e, encId!=null ? enc : undefined);
|
||||
if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link, el);
|
||||
if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link!, el??undefined);
|
||||
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
|
||||
if(a==='read'){ await setRead(e,!e.read); redraw(); }
|
||||
if(a==='get'){
|
||||
@@ -396,12 +493,12 @@ async function epAction(a: string, e, el, encId?: number){
|
||||
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
|
||||
}catch(err){
|
||||
// 409: somebody else has it starred or unplayed. Their reason, their words.
|
||||
if(!/one copy of this file/.test(err.message)) throw err;
|
||||
if(!confirm(err.message+'\n\nDelete it anyway?')) return;
|
||||
if(!/one copy of this file/.test(why(err))) throw err;
|
||||
if(!confirm(why(err)+'\n\nDelete it anyway?')) return;
|
||||
await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'});
|
||||
}
|
||||
toast('Deleted'); loadEntries(); loadFeeds(true);
|
||||
}
|
||||
}catch(err){ toast(err.message,true); }
|
||||
}catch(err){ toast(why(err),true); }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
document.getElementById('f').onsubmit = async e => {
|
||||
document.getElementById('f')!.onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
const msg = document.getElementById('msg');
|
||||
const msg = document.getElementById('msg')!;
|
||||
msg.textContent = '';
|
||||
const r = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* ---------------- player ---------------- */
|
||||
const audio=$('#audio');
|
||||
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, src?: string, moved?: boolean,
|
||||
const audio=$<HTMLAudioElement>('#audio');
|
||||
const player: {guid: string|null, feed: string|null, entry: Entry|null, enc?: number, src?: string, moved?: boolean,
|
||||
saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
|
||||
|
||||
/// Where a file plays from: DeadAir's playlist when the server says this person plays it
|
||||
/// shortened (#171, #181), whose times the entry is already given in, else the file itself.
|
||||
const mediaSrc=enc=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`;
|
||||
const mediaSrc=(enc: {id: number, deadair?: number|null})=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`;
|
||||
|
||||
/// hls.js, playing DeadAir's playlist where the browser cannot itself. Fetched the first time it
|
||||
/// is needed. Only Apple's browsers play it themselves: Chromium says it can ("maybe"), then fails
|
||||
@@ -31,7 +31,7 @@ function setSrc(url: string): Promise<void>|null{
|
||||
return ready.then(()=>{
|
||||
// Its playlist and audio requests join the trace of the tap that played them (#192).
|
||||
hls=new w.Hls({xhrSetup:(xhr: XMLHttpRequest)=>xhr.setRequestHeader('traceparent',traceparent())});
|
||||
hls.on(w.Hls.Events.ERROR,(_,d)=>{ if(d.fatal){ toast('Playback failed: '+d.details,true);
|
||||
hls.on(w.Hls.Events.ERROR,(_: string,d: {fatal: boolean, type: string, details: string})=>{ if(d.fatal){ toast('Playback failed: '+d.details,true);
|
||||
report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } });
|
||||
hls.loadSource(url); hls.attachMedia(audio);
|
||||
});
|
||||
@@ -49,8 +49,8 @@ function markPlayed(){
|
||||
}
|
||||
|
||||
/// Plays one of the item's files in the player bar: the one asked for, or its first playable one.
|
||||
function play(e,enc=e.enclosures.find(isPlayable)){
|
||||
if(!isPlayable(enc)){
|
||||
function play(e: Entry,enc=e.enclosures.find(isPlayable)){
|
||||
if(!enc||!isPlayable(enc)){
|
||||
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
|
||||
return;
|
||||
}
|
||||
@@ -76,17 +76,17 @@ function play(e,enc=e.enclosures.find(isPlayable)){
|
||||
player.marked=false;
|
||||
paintDead();
|
||||
}
|
||||
const go=()=>audio.play().catch(err=>toast('Playback failed: '+err.message,true));
|
||||
const go=()=>audio.play().catch((err: Error)=>toast('Playback failed: '+err.message,true));
|
||||
if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go();
|
||||
renderEntries();
|
||||
}
|
||||
function mediaSession(e,f){
|
||||
function mediaSession(e: Entry,f: Feed|undefined){
|
||||
if(!('mediaSession' in navigator)) return;
|
||||
navigator.mediaSession.metadata=new MediaMetadata({
|
||||
title:entryName(e).text, artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
|
||||
artwork:(e.image||(f&&f.image))?[{src:artSrc(e.image||f.image),sizes:'512x512'}]:[],
|
||||
artwork:(e.image||(f&&f.image))?[{src:artSrc((e.image||f?.image)!),sizes:'512x512'}]:[],
|
||||
});
|
||||
const h={play:()=>audio.play(),pause:()=>audio.pause(),
|
||||
const h: Record<string,()=>void>={play:()=>audio.play(),pause:()=>audio.pause(),
|
||||
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
|
||||
for(const k in h){ try{navigator.mediaSession.setActionHandler(k as MediaSessionAction,h[k])}catch{} }
|
||||
}
|
||||
@@ -94,7 +94,7 @@ audio.addEventListener('timeupdate',()=>{
|
||||
const d=audio.duration||player.entry?.duration||0;
|
||||
$('#pcur').textContent=clock(audio.currentTime);
|
||||
$('#pdur').textContent=clock(d);
|
||||
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000));
|
||||
if(d) $<HTMLInputElement>('#seek').value=String(Math.round(audio.currentTime/d*1000));
|
||||
// Only playing counts as moving: the seek to where you left off happens paused, and saving
|
||||
// that would write back whatever the list said, however old.
|
||||
if(!audio.paused) player.moved=true;
|
||||
@@ -117,29 +117,29 @@ function savePos(){
|
||||
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
|
||||
navigator.sendBeacon?.(
|
||||
// A beacon sends no headers, so the trace goes in the address (#192).
|
||||
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position?traceparent=${traceparent()}`,
|
||||
`/api/entries/${encodeURIComponent(player.feed!)}/${encodeURIComponent(player.guid!)}/position?traceparent=${traceparent()}`,
|
||||
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'}));
|
||||
}
|
||||
audio.addEventListener('pause',savePos);
|
||||
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play});
|
||||
audio.addEventListener('ended',()=>{savePos();markPlayed();$<HTMLButtonElement>('#pplay').innerHTML=ICON.play});
|
||||
// body.playing is what sets the EQ bars moving.
|
||||
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
|
||||
audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
|
||||
audio.addEventListener('play',()=>{ $<HTMLButtonElement>('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
|
||||
audio.addEventListener('pause',()=>{ $<HTMLButtonElement>('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
|
||||
for(const ev of ['play','pause','ended']) audio.addEventListener(ev,syncPlayButtons);
|
||||
|
||||
/// Every play button for what is playing shows pause, like the player bar's: a row's, the files
|
||||
/// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played.
|
||||
function syncPlayButtons(){
|
||||
const on=(guid,enc?)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
|
||||
const paint=(b,now,idle)=>{
|
||||
const on=(guid: string|null,enc?: number)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
|
||||
const paint=(b: HTMLElement,now: boolean,idle: string)=>{
|
||||
const label=now?'Pause':idle;
|
||||
if(b.title===label) return;
|
||||
b.title=label; b.setAttribute('aria-label',label); b.innerHTML=now?ICON.pause:ICON.play;
|
||||
};
|
||||
for(const b of $$('#eps .ep [data-a=play]')) paint(b,on(b.closest('.ep').dataset.guid),'Play');
|
||||
for(const b of $$('#eps .ep [data-a=play]')) paint(b,on(b.closest<HTMLElement>('.ep')!.dataset.guid!),'Play');
|
||||
for(const b of $$('#files [data-a=play][data-enc], #detail [data-a=play][data-enc]'))
|
||||
paint(b,on(S.sel,Number(b.dataset.enc)),'Play');
|
||||
const e=cur(); paint($('#tbPlay'),!!e&&on(e.guid),'Play the selected item');
|
||||
const e=cur(); paint($<HTMLButtonElement>('#tbPlay'),!!e&&on(e.guid),'Play the selected item');
|
||||
}
|
||||
for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening);
|
||||
window.addEventListener('beforeunload',savePos);
|
||||
@@ -157,27 +157,27 @@ audio.addEventListener('playing',()=>{
|
||||
asked=0; waited=0;
|
||||
});
|
||||
audio.addEventListener('error',()=>{ if(audio.error) report('player_error',{msg:`media error ${audio.error.code}: ${audio.error.message}`,...playing()}); });
|
||||
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
|
||||
$('#pback').onclick=()=>audio.currentTime-=15;
|
||||
$('#pfwd').onclick=()=>audio.currentTime+=30;
|
||||
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000};
|
||||
$('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
|
||||
$('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)};
|
||||
$<HTMLButtonElement>('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
|
||||
$<HTMLButtonElement>('#pback').onclick=()=>audio.currentTime-=15;
|
||||
$<HTMLButtonElement>('#pfwd').onclick=()=>audio.currentTime+=30;
|
||||
$<HTMLInputElement>('#seek').oninput=(e: Event)=>{const d=audio.duration;if(d)audio.currentTime=d*Number((e.target as HTMLInputElement).value)/1000};
|
||||
$<HTMLSelectElement>('#rate').onchange=(e: Event)=>{audio.playbackRate=+(e.target as HTMLSelectElement).value;localStorage.setItem('ipx.rate',(e.target as HTMLSelectElement).value)};
|
||||
$<HTMLInputElement>('#vol').oninput=(e: Event)=>{audio.volume=Number((e.target as HTMLInputElement).value)/100;localStorage.setItem('ipx.vol',(e.target as HTMLInputElement).value)};
|
||||
/// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the
|
||||
/// icon's shape and aria-pressed, never a colour.
|
||||
/// icon's shape (the silence still in it, or closed up) and aria-pressed, never a colour.
|
||||
function paintDead(){
|
||||
const b=$('#pdead'), f=S.feeds.find(x=>x.id===player.feed);
|
||||
const b=$<HTMLButtonElement>('#pdead'), f=S.feeds.find(x=>x.id===player.feed);
|
||||
b.hidden=!f||document.body.classList.contains('has-video');
|
||||
if(b.hidden) return;
|
||||
if(!f||b.hidden) return;
|
||||
b.setAttribute('aria-pressed',String(!!f.deadair));
|
||||
b.innerHTML=f.deadair?ICON.deadairOn:ICON.deadair;
|
||||
b.innerHTML=tile(f.deadair?'deadair-on':'deadair-off');
|
||||
b.title=f.deadair?'DeadAir is cutting the dead air from this show':'DeadAir: cut the dead air from this show';
|
||||
}
|
||||
$('#pdead').onclick=async()=>{
|
||||
$<HTMLButtonElement>('#pdead').onclick=async()=>{
|
||||
const f=S.feeds.find(x=>x.id===player.feed); if(!f) return;
|
||||
const on=!f.deadair;
|
||||
try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({deadair:on})}); }
|
||||
catch(err){ toast(err.message,true); return; }
|
||||
catch(err){ toast(why(err),true); return; }
|
||||
f.deadair=on; paintDead();
|
||||
const switched=await switchVersion();
|
||||
toast(!on?'DeadAir is off for this show':switched?'DeadAir is cutting the dead air from this show'
|
||||
@@ -188,14 +188,14 @@ $('#pdead').onclick=async()=>{
|
||||
/// turned on or off for its show. True if it changed version: a copy not made yet plays next time.
|
||||
async function switchVersion(){
|
||||
if(!player.guid) return false;
|
||||
const [feed,guid]=[player.feed,player.guid].map(encodeURIComponent);
|
||||
const [feed,guid]=[player.feed!,player.guid!].map(s=>encodeURIComponent(s));
|
||||
// Saved and waited for, not beaconed, so the entry asked for next has this place in it.
|
||||
if(audio.readyState){
|
||||
await api(`/api/entries/${feed}/${guid}/position`,{method:'POST',body:JSON.stringify({
|
||||
secs:Math.floor(audio.currentTime),duration:isFinite(audio.duration)?Math.floor(audio.duration):null})}).catch(()=>{});
|
||||
player.moved=false;
|
||||
}
|
||||
const e=await api(`/api/entries/${feed}/${guid}`).catch(()=>null);
|
||||
const e: Entry|null=await api(`/api/entries/${feed}/${guid}`).catch((): null=>null);
|
||||
const enc=e&&e.enclosures.find(x=>x.id===player.enc);
|
||||
if(!enc||mediaSrc(enc)===player.src) return false;
|
||||
const was=!audio.paused;
|
||||
@@ -205,13 +205,13 @@ async function switchVersion(){
|
||||
if(was) (wait||Promise.resolve()).then(()=>audio.play()).catch(()=>{});
|
||||
return true;
|
||||
}
|
||||
$('#pclose').onclick=()=>{savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
|
||||
$<HTMLButtonElement>('#pclose').onclick=()=>{savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
|
||||
// Called here, not left to the pause event: closing a player already paused fires none.
|
||||
syncListening()};
|
||||
(function restore(){
|
||||
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
|
||||
if(r){$('#rate').value=r;audio.playbackRate=+r}
|
||||
if(v){$('#vol').value=v;audio.volume=Number(v)/100}
|
||||
if(r){$<HTMLSelectElement>('#rate').value=r;audio.playbackRate=+r}
|
||||
if(v){$<HTMLInputElement>('#vol').value=v;audio.volume=Number(v)/100}
|
||||
})();
|
||||
|
||||
document.addEventListener('keydown',ev=>{
|
||||
@@ -223,34 +223,34 @@ document.addEventListener('keydown',ev=>{
|
||||
if(ev.key===' '&&player.guid){ev.preventDefault();audio.paused?audio.play():audio.pause()}
|
||||
else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15}
|
||||
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
|
||||
else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()}
|
||||
else if(ev.key==='/'){ev.preventDefault();$<HTMLInputElement>('#epSearch')?.focus()}
|
||||
else if(!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&!$('#modal').classList.contains('on')) typed(ev);
|
||||
});
|
||||
|
||||
// Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a
|
||||
// letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's
|
||||
// own shortcuts still work, or while a dialog is open.
|
||||
const GO={a:':all',d:':directory',l:':listening'};
|
||||
const GO: Record<string,string>={a:':all',d:':directory',l:':listening'};
|
||||
let gAt=0;
|
||||
function stepEntry(by){
|
||||
if(VIEWS[S.feed]?.url||!S.entries.length) return;
|
||||
function stepEntry(by: number){
|
||||
if(VIEWS[S.feed ?? '']?.url||!S.entries.length) return;
|
||||
const i=S.entries.findIndex(x=>x.guid===S.sel);
|
||||
const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))];
|
||||
selectEntry(e,true);
|
||||
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
|
||||
}
|
||||
function stepFeed(by){
|
||||
function stepFeed(by: number){
|
||||
const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id;
|
||||
if(!id) return;
|
||||
selectFeed(id);
|
||||
$(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'});
|
||||
}
|
||||
const KEYS={
|
||||
const KEYS: Record<string,()=>void>={
|
||||
j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1),
|
||||
J:()=>stepFeed(1), K:()=>stepFeed(-1),
|
||||
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while
|
||||
// they are disabled.
|
||||
o:()=>$('#tbPlay').click(), m:()=>$('#tbRead').click(), s:()=>$('#tbFlag').click(),
|
||||
o:()=>$<HTMLButtonElement>('#tbPlay').click(), m:()=>$<HTMLButtonElement>('#tbRead').click(), s:()=>$<HTMLButtonElement>('#tbFlag').click(),
|
||||
v:()=>{ const e=cur(); if(e&&e.link) window.open(e.link,'_blank','noopener'); },
|
||||
A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(),
|
||||
r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } },
|
||||
@@ -259,7 +259,7 @@ const KEYS={
|
||||
'?':()=>keysModal(),
|
||||
g:()=>{ gAt=Date.now(); },
|
||||
};
|
||||
function typed(ev){
|
||||
function typed(ev: KeyboardEvent){
|
||||
// The second key of a g pair counts only if it follows within a second and a half.
|
||||
const pair=Date.now()-gAt<1500; gAt=0;
|
||||
const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key];
|
||||
@@ -268,7 +268,7 @@ function typed(ev){
|
||||
}
|
||||
/// What ? shows: every key, grouped as Feedly's own list is.
|
||||
function keysModal(){
|
||||
const k=s=>`<kbd>${esc(s)}</kbd>`, g=c=>k('g')+' '+k(c);
|
||||
const k=(s: string)=>`<kbd>${esc(s)}</kbd>`, g=(c: string)=>k('g')+' '+k(c);
|
||||
const rows=[
|
||||
['Go to'],
|
||||
[g('a'),'All Subscriptions'],[g('d'),'Directory'],
|
||||
|
||||
288
web/src/signals.ts
Normal file
288
web/src/signals.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/* ---------------- signals ---------------- */
|
||||
/// Signals (#254): feeds you make from your own subscriptions, holding the items TypeSafe's Jev
|
||||
/// says are about the keywords you gave. A Signal is a place in the sidebar, `:signal:<id>`.
|
||||
|
||||
/// The Signals' counts again, which nothing else brings: reading an item, here or in its feed,
|
||||
/// and Jev picking one out both change them (#258). Once a burst has passed.
|
||||
const refreshSignals=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
|
||||
api('/api/signals').then((r: Signal[])=>{ S.signals=r; renderFeeds(); }).catch(()=>{});
|
||||
},400); }; })();
|
||||
|
||||
/// The Signal's id in a place's id, or null for anything else.
|
||||
function signalId(view: string|null){
|
||||
const m=/^:signal:(\d+)$/.exec(view||'');
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
function signalOf(view: string|null){
|
||||
const id=signalId(view);
|
||||
return id===null ? undefined : S.signals.find(s=>s.id===id);
|
||||
}
|
||||
|
||||
/// The Signals folder: first in the feed list, under All Subscriptions, open or closed as an OPML's
|
||||
/// is (#255, #256). As rows under the places, a long list of them pushed the feeds down.
|
||||
const SIGNALS=':signals';
|
||||
const sigArt=()=>`<span class="fart"><div class="art">${tile('signals')}</div></span>`;
|
||||
function feedRow(id: string, cls: string, html: string, open: ()=>void){
|
||||
const el=document.createElement('div');
|
||||
el.className='feed '+cls+(S.feed===id?' sel':'');
|
||||
el.tabIndex=0; el.dataset.id=id; el.innerHTML=html;
|
||||
el.onclick=()=>{ open(); nav(false); };
|
||||
return el;
|
||||
}
|
||||
/// A Signal's icon, drawn from /icons as a mask so it takes the theme's colour, or its initials.
|
||||
/// The name goes into an address and a style: only a style and a plain name are drawn (the server
|
||||
/// takes nothing else either).
|
||||
const ICON_ID=/^(solid|regular|brands)\/[a-z0-9-]{1,64}$/;
|
||||
const iconHTML=(icon: string)=>`<span class="ficon" style="--icon:url('/icons/${icon}.svg')"></span>`;
|
||||
/// Every Signal in the accent tint the folder has, icon or initials: tinted by a hash of its name,
|
||||
/// as a feed without art is, one came out a colour of its own for no reason anyone could see (#262).
|
||||
function signalTile(sg: {name: string, icon: string|null}){
|
||||
const inner=sg.icon&&ICON_ID.test(sg.icon) ? iconHTML(sg.icon) : esc(initials(sg.name));
|
||||
return `<div class="art ini sig" style="--tint:var(--accent)">${inner}</div>`;
|
||||
}
|
||||
const badgeHTML=(n: number)=>`<span class="badge${n?'':' zero'}" title="${n} unread">${n>999?'999+':n}</span>`;
|
||||
function signalFolder(list: HTMLElement, q: string){
|
||||
const mine=S.signals.filter(s=>!q||s.name.toLowerCase().includes(q));
|
||||
if(q&&!mine.length) return;
|
||||
const open=expanded.has(SIGNALS)||!!q;
|
||||
const n=S.signals.length;
|
||||
list.appendChild(feedRow(SIGNALS,'sigs',
|
||||
`<button class="chev" aria-expanded="${open}" title="Show or hide your signals" aria-label="Show or hide your signals">${ICON.caret}</button>`+
|
||||
sigArt()+`<div class="txt"><b>Signals</b><small>${n?plural(n,'signal'):'What matters, out of the noise'}</small></div>`+
|
||||
// No + of its own (#265): a row like any folder's. New signal is on the folder's page.
|
||||
badgeHTML(S.signals.reduce((k,s)=>k+s.unread,0)),
|
||||
()=>selectFeed(SIGNALS)));
|
||||
const row=list.lastElementChild!;
|
||||
$('.chev',row).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(SIGNALS); };
|
||||
if(!open) return;
|
||||
for(const sg of mine){
|
||||
const el=feedRow(':signal:'+sg.id,'child',
|
||||
`<span class="fart">${signalTile(sg)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${signalCounts(sg)}</small></div>`+badgeHTML(sg.unread),
|
||||
()=>selectFeed(':signal:'+sg.id));
|
||||
el.onpointerenter=el.ontouchstart=()=>prefetch(':signal:'+sg.id);
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder's page: every Signal, as an OPML's page lists its feeds.
|
||||
function renderSignals(){
|
||||
const unread=S.signals.reduce((k,s)=>k+s.unread,0);
|
||||
$('#count').textContent=`Signals: ${plural(S.signals.length,'signal')}, ${unread} unread`;
|
||||
$<HTMLButtonElement>('#tbRemove').disabled=true;
|
||||
syncTools(null);
|
||||
const box=$('#content');
|
||||
box.classList.add('plain');
|
||||
box.innerHTML=`
|
||||
<div class="fhead slim">
|
||||
<div class="art">${tile('signals')}</div>
|
||||
<div class="meta">
|
||||
<h2>Signals</h2>
|
||||
<div class="sub">Each one picks what matters out of the noise of your ${plural(S.feeds.length,'feed')} · ${unread} unread</div>
|
||||
</div>
|
||||
<div class="acts">
|
||||
<button class="btn ico" data-a="new" title="New signal" aria-label="New signal">${ICON.plus}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="childlist" id="siglist"></div>`;
|
||||
$<HTMLButtonElement>('#content .acts [data-a="new"]').onclick=()=>signalModal();
|
||||
const list=$('#siglist');
|
||||
if(!S.signals.length){
|
||||
list.innerHTML='<p class="empty">No signals yet. Make one to pull a subject out of every feed you subscribe to.</p>';
|
||||
return;
|
||||
}
|
||||
for(const sg of S.signals){
|
||||
const el=document.createElement('div');
|
||||
el.className='childrow';
|
||||
el.innerHTML=signalTile(sg)+`<div class="txt"><b>${esc(sg.name)}</b>`+
|
||||
`<small class="meta">${signalCounts(sg)}</small></div><span class="badge${sg.unread?'':' zero'}">${sg.unread}</span>`;
|
||||
el.onclick=()=>selectFeed(':signal:'+sg.id);
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
function signalHead(sg: Signal){
|
||||
const nokey=S.me&&!S.me.typesafe_key_set;
|
||||
return `
|
||||
<div class="fhead slim">
|
||||
${signalTile(sg)}
|
||||
<div class="meta">
|
||||
<h2>${esc(sg.name)}</h2>
|
||||
<div class="sub">Picked out of the noise of your ${plural(S.feeds.length,'feed')}: ${esc(sg.description||sg.keywords.join(', '))} · ${sg.unread} unread</div>
|
||||
${nokey?`<div class="sub" style="color:var(--warn)">No TypeSafe key yet, so nothing is being picked out. ${KEY_LINKS}</div>`:''}
|
||||
</div>
|
||||
<div class="acts">
|
||||
<button class="btn ico" data-a="settings" title="Edit this signal" aria-label="Edit this signal">${ICON.settings}</button>
|
||||
<button class="btn ico danger" data-a="rm" title="Delete this signal" aria-label="Delete this signal">${ICON.trash}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function signalAction(a: string, sg: Signal){
|
||||
if(a==='settings') signalModal(sg);
|
||||
if(a==='rm'){
|
||||
if(!confirm(`Delete the signal ${sg.name}? The items stay in their own feeds.`)) return;
|
||||
try{
|
||||
await api(`/api/signals/${sg.id}`,{method:'DELETE'});
|
||||
toast('Deleted'); await loadFeeds(true); selectFeed(':all');
|
||||
}catch(e){ toast(why(e),true); }
|
||||
}
|
||||
}
|
||||
|
||||
/// Making a Signal, or changing one.
|
||||
function signalModal(sg?: Signal){
|
||||
const nokey=S.me&&!S.me.typesafe_key_set;
|
||||
openModal(`<h3>${sg?'Edit signal':'New signal'}</h3>
|
||||
<div class="field"><label for="sgname">Name</label>
|
||||
<div class="inline"><button type="button" class="iconpick" id="sgicon" title="Choose an icon" aria-label="Choose an icon"
|
||||
aria-expanded="false"></button>
|
||||
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
|
||||
<div id="sgpicker" hidden>
|
||||
<div class="inline"><input type="search" id="sgiconq" placeholder="Search icons: robot, car, music…" autocomplete="off">
|
||||
<button type="button" class="dlink" id="sgjev" title="Pick an icon for me">Auto</button>
|
||||
<button type="button" class="dlink" id="sgnoicon">Initials</button></div>
|
||||
<div class="icongrid" id="sgicons" role="listbox" aria-label="Icons"></div>
|
||||
</div></div>
|
||||
<div class="field"><label for="sgkwin">Listen for</label>
|
||||
<div class="chips" id="sgkw">${chipsHTML(sg?.keywords||[],'sgkwin')}</div></div>
|
||||
<div class="field"><label for="sgdesc">In your own words (optional)</label>
|
||||
<input type="text" id="sgdesc" value="${esc(sg?.description||'')}" placeholder="AI tools for writing software: coding agents, vibe coding">
|
||||
<span class="hint">Every item in the feeds you subscribe to is read, the last 30 days of them
|
||||
and each new one, and the ones about this subject are pulled out: its name, these words and
|
||||
your sentence together. The rest stays noise, in its own feed. Each item is one call on your
|
||||
TypeSafe key.${sg?' A change here reads everything again.':''}</span></div>
|
||||
${nokey?`<p class="hint" style="color:var(--warn)">Without a TypeSafe key, nothing is picked out. ${KEY_LINKS}</p>`:''}
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
<button class="btn ico primary" id="sgsave" title="${sg?'Save':'Make signal'}" aria-label="${sg?'Save':'Make signal'}">${sg?ICON.check:ICON.plus}</button></div>`);
|
||||
wireChips($('#sgkw'));
|
||||
let icon=sg?.icon??null;
|
||||
const drawPick=()=>{ $('#sgicon').innerHTML=signalTile({name:$<HTMLInputElement>('#sgname').value||'?',icon}); };
|
||||
drawPick();
|
||||
$<HTMLInputElement>('#sgname').oninput=drawPick;
|
||||
const grid=$('#sgicons');
|
||||
const drawGrid=async()=>{
|
||||
const all=await iconList();
|
||||
const shown=iconMatches(all,$<HTMLInputElement>('#sgiconq').value,ICON_CELLS);
|
||||
grid.innerHTML=shown.length ? shown.map(([id,label])=>`<button type="button" class="iconcell" role="option" data-ic="${id}"
|
||||
aria-selected="${id===icon}" title="${esc(label)}" aria-label="${esc(label)}">${iconHTML(id)}</button>`).join('')
|
||||
: '<p class="hint">No icon matches that.</p>';
|
||||
};
|
||||
$<HTMLButtonElement>('#sgicon').onclick=()=>{
|
||||
const p=$('#sgpicker'), open=p.hidden;
|
||||
p.hidden=!open; $('#sgicon').setAttribute('aria-expanded',String(open));
|
||||
if(open){ drawGrid().catch(e=>toast(why(e),true)); $<HTMLInputElement>('#sgiconq').focus(); }
|
||||
};
|
||||
$<HTMLInputElement>('#sgiconq').oninput=()=>{ drawGrid().catch(()=>{}); };
|
||||
grid.onclick=(e: MouseEvent)=>{
|
||||
const b=(e.target as Element).closest<HTMLElement>('[data-ic]'); if(!b) return;
|
||||
icon=b.dataset.ic!; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false');
|
||||
};
|
||||
$<HTMLButtonElement>('#sgnoicon').onclick=()=>{ icon=null; drawPick(); $('#sgpicker').hidden=true; };
|
||||
$<HTMLButtonElement>('#sgjev').onclick=async()=>{
|
||||
// A spinner while it is picked: what picks it is nobody's concern.
|
||||
const b=$<HTMLButtonElement>('#sgjev'); b.disabled=true; b.classList.add('busy'); b.setAttribute('aria-busy','true');
|
||||
try{
|
||||
const got=await jevIcon($<HTMLInputElement>('#sgname').value.trim(),chipWords($('#sgkw')).filter((w): w is string=>!!w),
|
||||
$<HTMLInputElement>('#sgdesc').value.trim()||null);
|
||||
if(got){ icon=got; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false'); }
|
||||
else toast('No icon matches those words');
|
||||
}catch(e){ toast(why(e),true); }
|
||||
finally{ b.disabled=false; b.classList.remove('busy'); b.removeAttribute('aria-busy'); }
|
||||
};
|
||||
$<HTMLInputElement>('#sgname').focus();
|
||||
$<HTMLButtonElement>('#sgsave').onclick=async()=>{
|
||||
const name=$<HTMLInputElement>('#sgname').value.trim(), keywords=chipWords($('#sgkw')).filter((w): w is string=>!!w);
|
||||
if(!name){ $<HTMLInputElement>('#sgname').focus(); return; }
|
||||
if(!keywords.length){ $<HTMLInputElement>('#sgkwin').focus(); return; }
|
||||
const description=$<HTMLInputElement>('#sgdesc').value.trim()||null;
|
||||
// Only what changed: a new name, words or sentence has Jev read everything again, and saving
|
||||
// a new icon alone did that too while the dialog sent them all back each time.
|
||||
const body=JSON.stringify(sg ? signalChanges(sg,{name,keywords,description,icon}) : {name,keywords,description,icon});
|
||||
try{
|
||||
const r=sg ? await api(`/api/signals/${sg.id}`,{method:'PATCH',body})
|
||||
: await api('/api/signals',{method:'POST',body});
|
||||
// A new one made without an icon gets Jev's pick, behind, so making it does not wait (#263).
|
||||
if(!sg&&!icon) jevIcon(name,keywords,description)
|
||||
.then(got=>{ if(got) return api(`/api/signals/${r.id}`,{method:'PATCH',body:JSON.stringify({icon:got})}).then(refreshSignals); })
|
||||
.catch(()=>{});
|
||||
closeModal(); toast(sg?'Saved. Everything is read again':'Listening. What comes through shows up here');
|
||||
// Open, so the new one is there to see.
|
||||
if(!expanded.has(SIGNALS)) toggleGroup(SIGNALS);
|
||||
await loadFeeds(true); selectFeed(':signal:'+(sg?sg.id:r.id));
|
||||
}catch(e){ toast(why(e),true); }
|
||||
};
|
||||
}
|
||||
|
||||
/// What differs between a Signal and the dialog's fields, as PATCH takes it.
|
||||
function signalChanges(sg: Signal, now: {name: string, keywords: string[], description: string|null, icon: string|null}){
|
||||
const out: Record<string, unknown>={};
|
||||
if(now.name!==sg.name) out.name=now.name;
|
||||
if(now.keywords.join('\n')!==sg.keywords.join('\n')) out.keywords=now.keywords;
|
||||
if(now.description!==sg.description) out.description=now.description;
|
||||
if(now.icon!==sg.icon) out.icon=now.icon;
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Font Awesome Free's index, `[style/name, label, terms]`, fetched once, when the picker first
|
||||
/// opens: 2,883 icons are about 90 KB compressed, which the page has no other use for.
|
||||
const ICON_CELLS=120;
|
||||
let icons: Promise<[string,string,string][]>|null=null;
|
||||
function iconList(){
|
||||
if(!icons) icons=api('__ICONS__').catch((e: unknown)=>{ icons=null; throw e; });
|
||||
return icons!;
|
||||
}
|
||||
/// The icons with a word starting with each word typed, in their name, label or search terms. The
|
||||
/// first word typed ranks them: the icon named it, then one with it as a word of its name, then as
|
||||
/// a search term, then the rest. Matching anywhere in a word, "car" filled the grid with caret and
|
||||
/// cart (#261). At most `max`; nothing typed is the first `max`.
|
||||
function iconMatches(all: [string,string,string][], q: string, max: number){
|
||||
const words=q.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if(!words.length) return all.slice(0,max);
|
||||
const split=(s: string)=>s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
||||
const rank=([id,label,terms]: [string,string,string])=>{
|
||||
const name=id.slice(id.indexOf('/')+1), own=split(name), hay=[...own,...split(label),...split(terms)];
|
||||
if(!words.every(w=>hay.some(h=>h.startsWith(w)))) return -1;
|
||||
const w=words[0];
|
||||
return name===w ? 0 : own.includes(w) ? 1 : hay.includes(w) ? 2 : 3;
|
||||
};
|
||||
return all.map(i=>[rank(i),i] as const).filter(([r])=>r>=0).sort((a,b)=>a[0]-b[0]).slice(0,max).map(([,i])=>i);
|
||||
}
|
||||
|
||||
/// "12 items · 3 unread", as a feed's row counts its own.
|
||||
const signalCounts=(sg: Signal)=>`${plural(sg.items,'item')} · ${sg.unread} unread`;
|
||||
|
||||
/// The icons likeliest for a Signal: the best few for each word of its name, words and sentence,
|
||||
/// at most `max`. Jev picks from these, since it chooses among options and cannot name one.
|
||||
const ICON_STOP=new Set(['the','and','for','not','with','about','their','own','from','into','that','this','your']);
|
||||
function iconCandidates(all: [string,string,string][], name: string, keywords: string[], description: string|null, max=40){
|
||||
const words=[name,...keywords,description||''].join(' ').toLowerCase().split(/[^a-z0-9]+/).filter(w=>w.length>1&&!ICON_STOP.has(w));
|
||||
const out=new Map<string,string>();
|
||||
// A brand's icon stands for that brand, so it is offered only when its name is one of the
|
||||
// Signal's words: by its search terms, "disney" brought the Mandalorian, a Disney+ show (#264).
|
||||
const brandFor=(id: string, w: string)=>!id.startsWith('brands/')||id.slice(7).split('-').includes(w);
|
||||
for(const w of [...new Set(words)])
|
||||
for(const [id,label,terms] of iconMatches(all.filter(([id])=>brandFor(id,w)),w,6))
|
||||
if(out.size<max) out.set(id,`${label}: ${terms.split(' ').slice(0,8).join(', ')}`);
|
||||
return [...out];
|
||||
}
|
||||
/// Jev's pick of an icon, on the server's TypeSafe key, or null with nothing to choose from.
|
||||
async function jevIcon(name: string, keywords: string[], description: string|null): Promise<string|null>{
|
||||
const candidates=iconCandidates(await iconList(),name,keywords,description);
|
||||
if(!candidates.length) return null;
|
||||
const r=await api('/api/signals/icon',{method:'POST',body:JSON.stringify({name,keywords,description,candidates})});
|
||||
return r.icon;
|
||||
}
|
||||
|
||||
/// Where to get a TypeSafe key and where it goes, wherever a Signal says it has none: one click to
|
||||
/// each, rather than "in Settings" left to be found.
|
||||
const KEY_LINKS=`<a href="https://typesafe.ai" target="_blank" rel="noopener">Get one at typesafe.ai</a>, then
|
||||
<button type="button" class="dlink" data-keylink>add it in Settings</button>.`;
|
||||
/// Settings, open at the key's field.
|
||||
async function keySetting(){
|
||||
closeModal();
|
||||
await prefsModal();
|
||||
const k=$<HTMLInputElement>('#stskey'); k.scrollIntoView({block:'center'}); k.focus();
|
||||
}
|
||||
document.addEventListener('click',(e: MouseEvent)=>{
|
||||
if(!(e.target as Element).closest?.('[data-keylink]')) return;
|
||||
e.preventDefault(); keySetting().catch(err=>toast(why(err),true));
|
||||
});
|
||||
@@ -37,13 +37,13 @@ function setTheme(name = theme.name, mode = theme.mode, save = false){
|
||||
root.dataset.mode = !both ? 'light'
|
||||
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
|
||||
// The tab's icon in the same variant as the logo on the page.
|
||||
const fav = $('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode];
|
||||
const fav = $<HTMLLinkElement>('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode!]!;
|
||||
// The top bar's colour for an iPhone before iOS 26 to paint behind the status bar, as Android's
|
||||
// toolbar does; the page's where the bar is see-through (Glass). iOS 26 reads the bar itself.
|
||||
const tc = $('meta[name=theme-color]'), bar = $('#topbar');
|
||||
const tc = $<HTMLMetaElement>('meta[name=theme-color]'), bar = $('#topbar');
|
||||
if(tc){ const c = bar ? getComputedStyle(bar).backgroundColor : ''; tc.content = /^rgb\(/.test(c) ? c : getComputedStyle(root).backgroundColor; }
|
||||
const sel = $('#stheme'); if(sel) sel.value = theme.name;
|
||||
const ms = $('#smode'); if(ms) ms.value = theme.mode;
|
||||
const sel = $<HTMLSelectElement>('#stheme'); if(sel) sel.value = theme.name;
|
||||
const ms = $<HTMLSelectElement>('#smode'); if(ms) ms.value = theme.mode;
|
||||
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
|
||||
if(save) saveTheme();
|
||||
}
|
||||
@@ -62,6 +62,6 @@ systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTh
|
||||
// becomes its cookie, once.
|
||||
let name: string | null = null, mode: string | null = null;
|
||||
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
|
||||
if(OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
|
||||
if(name&&OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
|
||||
setTheme(name ?? undefined, mode ?? undefined, !!name);
|
||||
})();
|
||||
|
||||
170
web/src/types.ts
Normal file
170
web/src/types.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
// What ipx's API sends the page, as types for the type-check (#235), each named after the Rust
|
||||
// struct it mirrors in crates/ipx/src. Types only: swc strips this file to nothing, so it is not
|
||||
// in web/build.mjs's list; tsconfig.json's glob reads it. A field added there goes here too.
|
||||
// An Option is `T | null`, as serde writes None.
|
||||
|
||||
/// A feed in your list, `/api/feeds` and the live `feed_row` (web.rs FeedRow).
|
||||
interface Feed {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string | null;
|
||||
image: string | null;
|
||||
folder: string | null;
|
||||
category: string | null;
|
||||
feed_category: string | null;
|
||||
keywords: string[];
|
||||
blocked: string[];
|
||||
deadair: boolean;
|
||||
deadair_pause: number;
|
||||
allow_explicit: boolean;
|
||||
auto_download: boolean;
|
||||
max_new_per_check: number | null;
|
||||
/// The OPML subscription it came from.
|
||||
group: string | null;
|
||||
orphaned: boolean;
|
||||
managed: boolean;
|
||||
schedule: string | null;
|
||||
schedule_mins: number | null;
|
||||
every_mins: number;
|
||||
last_checked: number | null;
|
||||
next_check: number | null;
|
||||
last_error: string | null;
|
||||
failing: {reason: string, new_url: string | null} | null;
|
||||
entries: number;
|
||||
downloaded: number;
|
||||
unread: number;
|
||||
subscribers: number;
|
||||
pinned: boolean;
|
||||
private: boolean;
|
||||
private_by_address: boolean;
|
||||
}
|
||||
|
||||
/// An item, in a page of `/api/entries` (db.rs EntryRow).
|
||||
interface Entry {
|
||||
guid: string;
|
||||
feed_id: string;
|
||||
title: string | null;
|
||||
link: string | null;
|
||||
published: number | null;
|
||||
description: string | null;
|
||||
read: boolean;
|
||||
flagged: boolean;
|
||||
image: string | null;
|
||||
duration: number | null;
|
||||
episode: number | null;
|
||||
season: number | null;
|
||||
position: number;
|
||||
enclosures: Enclosure[];
|
||||
}
|
||||
|
||||
/// An item's file (db.rs EncRow).
|
||||
interface Enclosure {
|
||||
id: number;
|
||||
/// Seconds DeadAir's copy saves, when you play it from one.
|
||||
deadair?: number | null;
|
||||
deadair_pending?: boolean;
|
||||
feed_id: string;
|
||||
guid: string;
|
||||
url: string;
|
||||
mime: string | null;
|
||||
length: number | null;
|
||||
path: string | null;
|
||||
state: string;
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
/// `/api/me`.
|
||||
interface Me {
|
||||
name: string;
|
||||
admin: boolean;
|
||||
sign_out: string;
|
||||
blocked: string[];
|
||||
deadair_saved: number;
|
||||
theme: {name: string, mode: string} | null;
|
||||
version: string;
|
||||
/// A TypeSafe key is saved for Signals; the key itself never comes back.
|
||||
typesafe_key_set: boolean;
|
||||
}
|
||||
|
||||
/// A Signal in your sidebar, `/api/signals` (db.rs SignalRow).
|
||||
interface Signal {
|
||||
id: number;
|
||||
name: string;
|
||||
keywords: string[];
|
||||
/// What its owner means by it, in their words (#259).
|
||||
description: string | null;
|
||||
/// A Font Awesome Free icon, `solid/robot`, in place of its initials (#261).
|
||||
icon: string | null;
|
||||
/// The items it holds, read or not, and of those the unread.
|
||||
items: number;
|
||||
unread: number;
|
||||
}
|
||||
|
||||
/// A feed as the Directory lists it (web.rs PopularRow): never its address.
|
||||
interface DirFeed {
|
||||
id: string;
|
||||
title: string | null;
|
||||
image: string | null;
|
||||
subscribers: number;
|
||||
subscribed: boolean;
|
||||
/// Its id in your list, when it is yours.
|
||||
feed: string | null;
|
||||
category: string | null;
|
||||
subcategory: string | null;
|
||||
podcast: boolean;
|
||||
}
|
||||
|
||||
/// The page's own state (util.ts `S`).
|
||||
interface State {
|
||||
feeds: Feed[];
|
||||
signals: Signal[];
|
||||
/// The feed or place open: a feed's id, or a view such as ':all'.
|
||||
feed: string | null;
|
||||
entries: Entry[];
|
||||
total: number;
|
||||
offset: number;
|
||||
filter: string;
|
||||
q: string;
|
||||
/// The selected item's guid.
|
||||
sel: string | null;
|
||||
me: Me | null;
|
||||
sort: {col: string, dir: 'asc' | 'desc'};
|
||||
}
|
||||
|
||||
/// An account, `/api/users` (web.rs list_users).
|
||||
interface Account {
|
||||
id: number;
|
||||
name: string;
|
||||
admin: boolean;
|
||||
/// Has a password; none signs in through the proxy.
|
||||
password: boolean;
|
||||
created: number | null;
|
||||
last_login: number | null;
|
||||
}
|
||||
|
||||
/// A line of the daemon's log, `/api/logs` (logbuf.rs LogLine).
|
||||
interface LogLine {
|
||||
seq: number;
|
||||
ts: number;
|
||||
level: string;
|
||||
target: string;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
/// `/api/directory-admin`: the servers sharing with the directory and the feeds it blocks
|
||||
/// (directory lib.rs Instance, Blocked).
|
||||
interface DirAdmin {
|
||||
instances: {name: string, feeds: number, subscribers: number}[];
|
||||
blocked: {id: string, url: string | null, title: string | null, at: number}[];
|
||||
}
|
||||
|
||||
/// One of a listed feed's latest items, on its page in the Directory (web.rs ListedItem): never
|
||||
/// its files.
|
||||
interface ListedItem {
|
||||
guid: string;
|
||||
title: string | null;
|
||||
link: string | null;
|
||||
published: number | null;
|
||||
duration: number | null;
|
||||
description: string | null;
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
'use strict';
|
||||
// `any`: the page reads .value, .dataset and .onclick off whatever it looks up, and the
|
||||
// smoke test, not the type checker, is what makes sure a selector exists.
|
||||
const $ = (s: string, r: ParentNode = document): any => r.querySelector(s);
|
||||
const $$ = (s: string, r: ParentNode = document): any[] => [...r.querySelectorAll(s)];
|
||||
const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const $ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = document): T => r.querySelector<T>(s)!;
|
||||
const $$ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = document): T[] => [...r.querySelectorAll<T>(s)];
|
||||
/// What went wrong, from whatever a catch caught: the page throws Errors, but a catch cannot know.
|
||||
const why = (e: unknown): string => e instanceof Error ? e.message : String(e);
|
||||
const ESCAPED: Record<string,string> = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
||||
const esc = (s: string|null|undefined) => (s??'').replace(/[&<>"']/g,c=>ESCAPED[c]);
|
||||
// Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com
|
||||
// License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the
|
||||
// ones used, so there is no font to download and nothing is fetched from anyone else. Each takes
|
||||
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
|
||||
const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
|
||||
const ICON={
|
||||
const fa=(box: string,body: string)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
|
||||
/// An app-icon tile (web/signals.svg, deadair-on.svg, ...) in the mode's own variant, as the logo is.
|
||||
const tile=(name: string)=>`<img class="tile onlight" src="/${name}.svg" alt=""><img class="tile ondark" src="/${name}-dark.svg" alt="">`;
|
||||
const ICON: Record<string,string>={
|
||||
share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes
|
||||
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
|
||||
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
|
||||
@@ -21,10 +26,6 @@ const ICON={
|
||||
// for the outline's stroke, so the two draw the same size.
|
||||
pin:fa('-18 -18 420 548','<path fill="none" stroke="currentColor" stroke-width="36" stroke-linejoin="round" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack, outlined
|
||||
pinOn:fa('-18 -18 420 548','<path fill="currentColor" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack
|
||||
// DeadAir (#171): a waveform. Off for this show, outlined with the silence still in its
|
||||
// middle; on, solid with the gap closed up. Padded like pin for the outline's stroke.
|
||||
deadair:fa('-16 -16 544 544','<g fill="none" stroke="currentColor" stroke-width="24"><rect x="14" y="200" width="44" height="112" rx="22"/><rect x="84" y="136" width="44" height="240" rx="22"/><rect x="154" y="96" width="44" height="320" rx="22"/><rect x="314" y="96" width="44" height="320" rx="22"/><rect x="384" y="136" width="44" height="240" rx="22"/><rect x="454" y="200" width="44" height="112" rx="22"/></g>'),
|
||||
deadairOn:fa('-16 -16 544 544','<g fill="currentColor"><rect x="59" y="200" width="44" height="112" rx="22"/><rect x="129" y="136" width="44" height="240" rx="22"/><rect x="199" y="96" width="44" height="320" rx="22"/><rect x="269" y="96" width="44" height="320" rx="22"/><rect x="339" y="136" width="44" height="240" rx="22"/><rect x="409" y="200" width="44" height="112" rx="22"/></g>'),
|
||||
scan:fa('0 0 512 512','<path fill="currentColor" d="M65.9 228.5c13.3-93 93.4-164.5 190.1-164.5 53 0 101 21.5 135.8 56.2 .2 .2 .4 .4 .6 .6l7.6 7.2-47.9 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 53.4-11.3-10.7C390.5 28.6 326.5 0 256 0 127 0 20.3 95.4 2.6 219.5 .1 237 12.2 253.2 29.7 255.7s33.7-9.7 36.2-27.1zm443.5 64c2.5-17.5-9.7-33.7-27.1-36.2s-33.7 9.7-36.2 27.1c-13.3 93-93.4 164.5-190.1 164.5-53 0-101-21.5-135.8-56.2-.2-.2-.4-.4-.6-.6l-7.6-7.2 47.9 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 320c-8.5 0-16.7 3.4-22.7 9.5S-.1 343.7 0 352.3l1 127c.1 17.7 14.6 31.9 32.3 31.7S65.2 496.4 65 478.7l-.4-51.5 10.7 10.1c46.3 46.1 110.2 74.7 180.7 74.7 129 0 235.7-95.4 253.4-219.5z"/>'), // solid/arrows-rotate
|
||||
download:fa('0 0 448 512','<path fill="currentColor" d="M256 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 210.7-41.4-41.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c12.5 12.5 32.8 12.5 45.3 0l96-96c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 242.7 256 32zM64 320c-35.3 0-64 28.7-64 64l0 32c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-32c0-35.3-28.7-64-64-64l-46.9 0-56.6 56.6c-31.2 31.2-81.9 31.2-113.1 0L110.9 320 64 320zm304 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/>'), // solid/download
|
||||
save:fa('0 0 448 512','<path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>'), // solid/floppy-disk
|
||||
@@ -58,33 +59,41 @@ const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
|
||||
// One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
|
||||
// dialog's confirm button carries the icon of what it does. Words go in the tooltip.
|
||||
// The page's own buttons name their icon; this draws it in, ahead of any label they carry.
|
||||
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]);
|
||||
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon!]);
|
||||
|
||||
/// An episode whose DeadAir copy is still being made: it plays as it is until then, and the line
|
||||
/// becomes how much shorter it is once the copy is ready (the dead_air event).
|
||||
const DEAD_PENDING='<span class="dapend" title="DeadAir is still working on this episode. Until it is done, it plays at its full length.">Cutting dead air…</span>';
|
||||
/// What DeadAir took off an episode, as the list says it.
|
||||
function shorter(secs){ return secs<60?`${secs} s shorter`:`${Math.round(secs/60)} min shorter`; }
|
||||
function shorter(secs: number){ return secs<60?`${secs} s shorter`:`${Math.round(secs/60)} min shorter`; }
|
||||
/// A total of time saved: "3 h 12 min", "12 min", "40 s".
|
||||
function spanText(secs){
|
||||
function spanText(secs: number){
|
||||
const h=Math.floor(secs/3600), m=Math.floor(secs%3600/60);
|
||||
return h?`${h} h ${m} min`:m?`${m} min`:`${secs} s`;
|
||||
}
|
||||
/// One trace for each thing you do (#190): the requests a tap or a key sets off share a trace id,
|
||||
/// sent as traceparent, so Tempo shows them together. getRandomValues, not randomUUID, which a
|
||||
/// LAN address over plain HTTP does not have.
|
||||
let traceId='';
|
||||
/// LAN address over plain HTTP does not have. A trace ends after a minute with no request in it:
|
||||
/// the requests a live update or a woken laptop sets off hours later used to join the last tap's
|
||||
/// trace, which then lasted nine hours (#219). A play's position saves, every ten seconds, keep it.
|
||||
let traceId='', traceAt=0;
|
||||
const hex=(n: number)=>Array.from(crypto.getRandomValues(new Uint8Array(n)),b=>b.toString(16).padStart(2,'0')).join('');
|
||||
for(const t of ['pointerdown','keydown']) window.addEventListener(t,()=>{ traceId=hex(16); },{capture:true,passive:true});
|
||||
function traceparent(){ return `00-${traceId||=hex(16)}-${hex(8)}-01`; }
|
||||
for(const t of ['pointerdown','keydown']) window.addEventListener(t,()=>{ traceId=hex(16); traceAt=Date.now(); },{capture:true,passive:true});
|
||||
function traceparent(){
|
||||
const now=Date.now();
|
||||
if(!traceId||now-traceAt>60_000) traceId=hex(16);
|
||||
traceAt=now;
|
||||
return `00-${traceId}-${hex(8)}-01`;
|
||||
}
|
||||
|
||||
/// Something that went wrong, or how long something took, in the browser, for the server's log
|
||||
/// (#190). The same thing at most once a minute: a failure in a loop would otherwise send a line
|
||||
/// a frame.
|
||||
const reported=new Map<string,number>();
|
||||
function report(ev: string, o: Record<string,any>={}){
|
||||
// A timing comes of something someone did, not a loop, so every one is sent.
|
||||
const key=ev+(o.msg||''), now=Date.now();
|
||||
if(o.msg==='signed out' || now-(reported.get(key)||0)<60_000) return;
|
||||
if(o.msg==='signed out' || (o.ms==null && now-(reported.get(key)||0)<60_000)) return;
|
||||
reported.set(key,now);
|
||||
fetch('/api/client',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json',traceparent:traceparent()},
|
||||
body:JSON.stringify({ev,...o})}).catch(()=>{});
|
||||
@@ -95,13 +104,15 @@ window.addEventListener('unhandledrejection',e=>report('rejection',{msg:String(e
|
||||
async function api(url: string, opts?: RequestInit): Promise<any>{
|
||||
const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts});
|
||||
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
|
||||
if(!r.ok) throw new Error(await r.text().catch(()=>'')||String(r.status));
|
||||
return r.status===204?null:r.json().catch(()=>null);
|
||||
// A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a
|
||||
// whole 502 page (#219).
|
||||
if(!r.ok) throw new Error((!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>''))||`${r.status} ${r.statusText}`.trim());
|
||||
return r.status===204?null:r.json().catch((): null=>null);
|
||||
}
|
||||
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
|
||||
// address it is undefined, so fall back to the old selection-based copy.
|
||||
async function copyText(text,btn){
|
||||
const flash=ok=>{
|
||||
async function copyText(text: string,btn?: HTMLElement){
|
||||
const flash=(ok: boolean)=>{
|
||||
if(!btn) return;
|
||||
// The button is an icon, so it is the markup that has to come back, not just its text.
|
||||
const was=btn.innerHTML;
|
||||
@@ -135,14 +146,14 @@ async function copyText(text,btn){
|
||||
///
|
||||
/// ponytail: a guess from the address. A private feed whose key has another name is shared
|
||||
/// without asking; add its pattern here when one turns up.
|
||||
async function share(title: string, url: string, btn?){
|
||||
async function share(title: string, url: string, btn?: HTMLElement){
|
||||
if(/patreon\.com\/|\/\/[^/]*@|[?&][^=]*(auth|token|key|secret|pass|sig)[^=]*=/i.test(url)
|
||||
&& !confirm('This address looks like it includes your own access to the feed, and anyone you '
|
||||
+'send it to could use it as you.\n\nShare it anyway?')) return;
|
||||
if(navigator.share){
|
||||
// Cancelling the sheet rejects too, and is not a failure worth a word.
|
||||
try{ await navigator.share({title, url}); }
|
||||
catch(e){ if(e.name!=='AbortError') toast(e.message,true); }
|
||||
catch(e){ if((e as Error)?.name!=='AbortError') toast(why(e),true); }
|
||||
return;
|
||||
}
|
||||
await copyText(url,btn);
|
||||
@@ -155,12 +166,12 @@ function toast(msg: string, bad?: boolean){
|
||||
$('#toasts').appendChild(t);
|
||||
setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200);
|
||||
}
|
||||
const clock = s => {
|
||||
const clock = (s: number) => {
|
||||
s=Math.max(0,Math.floor(s||0));
|
||||
const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;
|
||||
return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;
|
||||
};
|
||||
const ago = t => {
|
||||
const ago = (t: number|null) => {
|
||||
if(!t) return 'never';
|
||||
const d=(Date.now()/1000)-t;
|
||||
if(d<3600) return Math.max(1,Math.round(d/60))+'m ago';
|
||||
@@ -168,16 +179,16 @@ const ago = t => {
|
||||
if(d<2592000) return Math.round(d/86400)+'d ago';
|
||||
return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'});
|
||||
};
|
||||
const dateOf = t => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
|
||||
const dateOf = (t: number|null) => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
|
||||
// A podcast episode is tens of MB, an article's image a few KB: whole MB made the small ones "0 MB".
|
||||
const mb = n => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB'
|
||||
const mb = (n: number|null) => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB'
|
||||
: n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB';
|
||||
const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
|
||||
const plural=(n,word)=>`${n} ${word}${n===1?'':'s'}`;
|
||||
const initials = (s: string|null) => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
|
||||
const plural=(n: number,word: string)=>`${n} ${word}${n===1?'':'s'}`;
|
||||
// An initials tile takes one of these, by a hash of the name, so neighbours rarely match.
|
||||
const TINTS=['var(--accent)','var(--good)','var(--dim)','color-mix(in srgb,var(--accent),var(--good))'];
|
||||
const tint=name=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; };
|
||||
function tileHTML(name,cls){
|
||||
const tint=(name: string)=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; };
|
||||
function tileHTML(name: string,cls?: string){
|
||||
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
|
||||
}
|
||||
/// Every image a feed names comes from iPX, which keeps a copy: fast after the first time, and
|
||||
@@ -186,26 +197,26 @@ function tileHTML(name,cls){
|
||||
function artSrc(url: string){
|
||||
return /^https?:\/\//i.test(url) ? '/api/art?u='+encodeURIComponent(url) : url;
|
||||
}
|
||||
function artHTML(url: string | null, name: string, cls?: string){
|
||||
function artHTML(url: string | null | undefined, name: string, cls?: string){
|
||||
return url
|
||||
? `<img class="art ${cls||''}" src="${esc(artSrc(url))}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
|
||||
: tileHTML(name,cls);
|
||||
}
|
||||
/// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own.
|
||||
function folderArt(f,kids){
|
||||
function folderArt(f: Feed,kids: Feed[]){
|
||||
const art=kids.filter(c=>c.image).slice(0,4);
|
||||
if(art.length<4) return artHTML(f.image,f.title||f.id);
|
||||
// Tinted underneath, so art that fails to load leaves colour behind rather than a hole.
|
||||
return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=>
|
||||
`<img src="${esc(artSrc(c.image))}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
|
||||
`<img src="${esc(artSrc(c.image!))}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
|
||||
}
|
||||
|
||||
/// The sidebar slides over the page on a phone, so it needs a scrim to tap away.
|
||||
function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=!on; }
|
||||
function nav(on: boolean){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=!on; }
|
||||
|
||||
/* ---------------- state ---------------- */
|
||||
const S = {
|
||||
feeds:[],
|
||||
const S: State = {
|
||||
feeds:[], signals:[],
|
||||
// Which feed (or place) and which tab were open last time, so a refresh lands back where
|
||||
// you were instead of jumping to the first feed alphabetically.
|
||||
feed:(()=>{ try{ return localStorage.getItem('ipx.feed'); }catch{ return null; } })(),
|
||||
@@ -213,25 +224,25 @@ const S = {
|
||||
filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all',
|
||||
q:'', sel:null, me:null,
|
||||
// The item table's order, kept across visits. The server sorts: a list arrives fifty at a time.
|
||||
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')); }catch{ return null; } })()
|
||||
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')??'null'); }catch{ return null; } })()
|
||||
||{col:'published',dir:'desc'},
|
||||
};
|
||||
const LIMIT = 50;
|
||||
|
||||
const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']];
|
||||
const UNIT_MINS = {m:1, h:60, d:1440, w:10080};
|
||||
const UNIT_MINS: Record<string,number> = {m:1, h:60, d:1440, w:10080};
|
||||
|
||||
/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes".
|
||||
function splitEvery(m){
|
||||
function splitEvery(m: number){
|
||||
if(!m) return {n:1, u:'h'};
|
||||
for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u};
|
||||
return {n:m, u:'m'};
|
||||
}
|
||||
function unitOptions(sel){
|
||||
function unitOptions(sel: string){
|
||||
return UNITS.map(([v,l]) =>
|
||||
`<option value="${v}"${sel===v?' selected':''}>${l}</option>`).join('');
|
||||
}
|
||||
function everyText(m){
|
||||
function everyText(m: number){
|
||||
if(!m) return '\u2014';
|
||||
const {n,u} = splitEvery(m);
|
||||
const name = {m:'min', h:'hour', d:'day', w:'week'}[u];
|
||||
|
||||
Reference in New Issue
Block a user