Compare commits
217 Commits
v0.9.0
...
directory-
| Author | SHA1 | Date | |
|---|---|---|---|
| 25f4d23112 | |||
| 69958d6926 | |||
| 0051590c71 | |||
| 83e7ee817b | |||
| 68ac180c9a | |||
| 0875bb9e39 | |||
| 2dd6e50065 | |||
| 91125700d9 | |||
| c0f8424209 | |||
| 50ffc5c81b | |||
| 0045396efd | |||
| 8d5a86d02a | |||
| 4cb2426b49 | |||
| 700037c368 | |||
| 579fe27b15 | |||
| 2ee2382c9b | |||
| 0cb6c84abe | |||
| f23274284c | |||
| 1c6f4eb321 | |||
| bc650153ce | |||
| 954399ed5d | |||
| 10a78efcc3 | |||
| 38c1abba1d | |||
| fa949f467d | |||
| 4eab53bf5d | |||
| 44e75df99e | |||
| a3708769a4 | |||
| ee7ab36730 | |||
| 42f983f81a | |||
| 34a0cdf914 | |||
| 7ff73f1a8b | |||
| cd099380d1 | |||
| 751a45f132 | |||
| 7ff6f86e7b | |||
| 77a414d8df | |||
| 3bab6210c5 | |||
| 0c820bbde1 | |||
| 3e58447f12 | |||
| f9870938bf | |||
| c47aa57909 | |||
| ed82166745 | |||
| 83bda97cce | |||
| beded23530 | |||
| c2cea0fc5b | |||
| fca704f7ff | |||
| e459d1474c | |||
| 7c3d36b29a | |||
| 78eb255a78 | |||
| afd3cf448d | |||
| c28accb8e4 | |||
| 8dc6dd4ce6 | |||
| f032d50ed3 | |||
| 12fadbd0f8 | |||
| 0d930e75ee | |||
| 29f1cbce29 | |||
| a985cfafef | |||
| 973eeb67c7 | |||
| 9fc1e7e248 | |||
| b51df33bb6 | |||
| cd9aef36a1 | |||
| 35fca0a6a2 | |||
| 3d005da847 | |||
| 6c931d0485 | |||
| 9648a79f3f | |||
| e5b9b0cfdb | |||
| ba9759f44f | |||
| c214b3e79a | |||
| e56bc9e8dd | |||
| b2fe1c725d | |||
| 7bec82d053 | |||
| 4ccf2e5511 | |||
| 089202f72b | |||
| f29d493a0e | |||
| 9ea2a9250c | |||
| 95202ec411 | |||
| 17fbf79db8 | |||
| d6c0e3e7a6 | |||
| 527a90ed2d | |||
| a67b949960 | |||
| 845448a754 | |||
| 00bc039964 | |||
| 111a0e29a4 | |||
| 827cc7489f | |||
| 16b0686aab | |||
| 63e192767d | |||
| 8687e4ff1b | |||
| 1e7a858e9c | |||
| c37bcf816b | |||
| 4ac1e6dbbc | |||
| 22a99110e8 | |||
| cb92d5e355 | |||
| e3f0267814 | |||
| f4a8ba4ea8 | |||
| 4c1d230044 | |||
| ecbc253894 | |||
| b4c115d79a | |||
| 4e417389fe | |||
| 19f77a21d9 | |||
| 65ca481525 | |||
| 92cc0cf4ec | |||
| 6a69818ef0 | |||
| 2de5f81202 | |||
| 4db5ee5c44 | |||
| 6ddd6444f9 | |||
| e710103ba6 | |||
| dc340dd4e9 | |||
| ebecc12193 | |||
| 33e031dd13 | |||
| 60cbb7aff6 | |||
| ec55682c13 | |||
| 15291298aa | |||
| c426ab6314 | |||
| d5edead011 | |||
| c7eef3e516 | |||
| a12aa59970 | |||
| a553931c76 | |||
| 46f9370018 | |||
| 70c49921ab | |||
| 0f93d1bd0d | |||
| 4f4f2e8d95 | |||
| c1b73b3dbe | |||
| fc0d6bf98b | |||
| a44e2b4235 | |||
| 2098343621 | |||
| 5684a02e79 | |||
| 50c3b9db64 | |||
| 39ae43c80e | |||
| c9964778cb | |||
| a553d050ca | |||
| 9c2f3eaf1b | |||
| c63f70ad0c | |||
| bbdcbe213f | |||
| 8c4a5f396b | |||
| b43ba89778 | |||
| f1d360420c | |||
| 697e907c86 | |||
| 894dbbe31d | |||
| 552d20443e | |||
| 367f431e1f | |||
| ef8e2526f2 | |||
| 5058adc93e | |||
| 9e39f2d756 | |||
| b86062b97a | |||
| 95acde3046 | |||
| 0b4f5443b7 | |||
| e699baa22a | |||
| 00bd58ac9d | |||
| cc17b1ddab | |||
| 119336a20d | |||
| 02a46bb500 | |||
| 3e384f34f9 | |||
| d238681ec1 | |||
| 0395717c14 | |||
| 8ce68a881a | |||
| e7c59489ee | |||
| f7f4b466dc | |||
| ec8fd5dd86 | |||
| a387012c69 | |||
| a00516687a | |||
| 38ebc7b02b | |||
| 54d827f655 | |||
| e2593eaa50 | |||
| d52ecfcbd7 | |||
| 142610e8b8 | |||
| 43acc62259 | |||
| 65fdab8b13 | |||
| 4d312a87ec | |||
| ef5bdb4244 | |||
| c7f13eea2f | |||
| 7a134b810f | |||
| 79bc006a30 | |||
| 9084b61bb6 | |||
| 0cc956b7c6 | |||
| db87bc0f42 | |||
| 527777efdf | |||
| 4a26c73b82 | |||
| 98ed9b6498 | |||
| 57ab419718 | |||
| 2799704d30 | |||
| e8fd3fe9ed | |||
| 448e557272 | |||
| 36b16c7f5d | |||
| 928cbaf8f4 | |||
| 0945f3a9f4 | |||
| e9f2832e1e | |||
| d4e00be085 | |||
| 66e40e3c71 | |||
| 4f8b3d6a1d | |||
| 8ce0a4cb27 | |||
| 549ae33f06 | |||
| 9e7c93e149 | |||
| 1724346da7 | |||
| 8c5eddd783 | |||
| 469467bf04 | |||
| 95a8633877 | |||
| 00f6293b95 | |||
| bae22e553e | |||
| 7d55d99fac | |||
| a5de4ae06c | |||
| 4ed2d59311 | |||
| 2004da3459 | |||
| c6980c355d | |||
| ce221cee18 | |||
| f57f824535 | |||
| 7490a3a9ac | |||
| 5be629427a | |||
| bf785299b0 | |||
| 5967aa1e57 | |||
| 9e238bfc75 | |||
| 10b7ccd424 | |||
| d8db785681 | |||
| f9c9c2b7cc | |||
| 3cb36ab8b7 | |||
| 65b5eacdb4 | |||
| 4769a2ebe1 | |||
| 70e0341348 | |||
| e16dace9c0 |
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(rtk grep *)",
|
|
||||||
"Bash(rtk read *)",
|
|
||||||
"Bash(rtk git *)"
|
|
||||||
],
|
|
||||||
"additionalDirectories": [
|
|
||||||
"/config/.claude/skills/security-audit",
|
|
||||||
"/config/security-audit-skill",
|
|
||||||
"/config/.cargo/registry"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
380
.claude/skills/ipx-prod-check/SKILL.md
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
---
|
||||||
|
name: ipx-prod-check
|
||||||
|
description: Look for problems in production ipx (the ipx-web, iPX and ipx-directory containers on Tower, Tower itself and the monitoring that watches them) from their logs in Loki, their traces in Tempo and the metrics in Prometheus, and file what is found as Gitea issues. Use when asked to check on production, look for issues or errors in ipx, see why something is slow or failing in production, review the logs, or investigate a report about the live site.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Checking production ipx
|
||||||
|
|
||||||
|
Production is three containers in the Arcane project `ipx` on Tower
|
||||||
|
(`/mnt/fast/arcane/projects/ipx/compose.yaml`), one request passing through them in this order:
|
||||||
|
|
||||||
|
| container | what | logs, in Loki | traces, in Tempo | metrics, in Prometheus |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `ipx-web` | Caddy: serves the page, passes `/api` and `/media` to ipx (#203). Port 8099, the LAN and the tunnel | `{container="ipx-web"}`, JSON: a line a request (`http.log.access`), its own errors | service `ipx-web`: a span a request, passing `traceparent` on to ipx (#213) | none |
|
||||||
|
| `iPX` | the daemon: API, scans, downloads, DeadAir | `{container="iPX"}`, JSON (`IPX_LOG_FORMAT=json`) | service `ipx`; passes `traceparent` to the directory | `job="ipx"`, its `/metrics` |
|
||||||
|
| `ipx-directory` | the directory of public podcasts, `directory.ipxreader.app` (#204, #214) | `{container="ipx-directory"}`, JSON (`DIRECTORY_LOG_FORMAT=json`) | service `ipx-directory`: a span a request, joining ipx's trace when ipx called; a trace a refresher round (#213) | none |
|
||||||
|
|
||||||
|
Under them, Tower itself: `job="node"` (the node exporter: load, CPU, memory, disks). The Grafana
|
||||||
|
dashboard `iPX` draws all of it in that order (`grafana/dashboard.py`). A trace without the
|
||||||
|
production tag (`resource.deployment.environment.name="production"`, on all three) is a daemon run
|
||||||
|
by hand, not production. Since #213 a request is one trace across the services it touched: a
|
||||||
|
Directory page is ipx-web, then ipx, then the directory, so start from the trace, not the logs. Alloy ships the logs; Loki, Tempo and
|
||||||
|
Prometheus publish no query port to this machine, so use the script beside this file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
Q=.claude/skills/ipx-prod-check/query.py
|
||||||
|
$Q logs '<LogQL>' [since] # lines, newest first (500 at most)
|
||||||
|
$Q metric '<LogQL metric>' [since] # $range becomes `since`
|
||||||
|
$Q traces '<TraceQL>' [since] # slowest first
|
||||||
|
$Q trace <trace id> # one trace as a tree, with its log lines
|
||||||
|
$Q prom '<PromQL>' [since] # Prometheus, now; $range becomes `since`
|
||||||
|
```
|
||||||
|
|
||||||
|
`since` is `30m`, `1h`, `24h`, `7d`. Default to `24h`; widen it to see whether something is new.
|
||||||
|
"Since the last deploy" is since that container started (each deploys on its own):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
for c in iPX ipx-web ipx-directory; do S=$(docker inspect $c -f '{{.State.StartedAt}}'); echo "$c $(( ($(date +%s) - $(date -d "$S" +%s)) / 60 + 1 ))m"; done
|
||||||
|
```
|
||||||
|
|
||||||
|
Start every check by seeing that all three run, since Loki cannot see a container that is not:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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
|
||||||
|
directory, Tower and the monitoring itself, in that order.
|
||||||
|
|
||||||
|
## ipx
|
||||||
|
|
||||||
|
### What the log carries
|
||||||
|
|
||||||
|
Every line has `timestamp`, `level`, `message` and `target`; lines inside a span have `span` (the
|
||||||
|
innermost: `{"name":"feed","feed":"x"}`). Loki's `| json` flattens it to `span_name`, `span_feed`.
|
||||||
|
Lines inside a traced span, requests and scans, also carry `trace_id` and `span_id`: give the
|
||||||
|
`trace_id` to `$Q trace` to see the whole request or scan. (From 2026-09-29 16:30 UTC; before that,
|
||||||
|
lines had no trace id, the access log's time was `ms`, and each event was logged twice, words under
|
||||||
|
`ipx::scan` and fields under `ipx::io`.)
|
||||||
|
|
||||||
|
| target | fields | what |
|
||||||
|
|---|---|---|
|
||||||
|
| `ipx::http` | `method`, `path`, `route`, `status`, `duration_ms` | one per web request; `route` is the pattern, empty for an unrouted path |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
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
|
||||||
|
updated), `download_done` (bytes), `download_error` (msg, url),
|
||||||
|
`torrent_deferred`, `reaped`, `scan_done` (feeds checked), `reap_done`, `status` (feeds, pending,
|
||||||
|
downloaded: the healthcheck's, every 30s), `deadair_done` (feed, enclosure, `saved_s` at the
|
||||||
|
default pause), `error` (msg). DeadAir also logs `DeadAir found the silences` (`cuts`, `saved_s`,
|
||||||
|
`took_ms`) under `ipx::deadair`, and warns `DeadAir could not read a file` when ffmpeg fails on
|
||||||
|
one, which it then passes over until the next restart.
|
||||||
|
|
||||||
|
`error.type` is the HTTP status (`404`, `503`) or one of `dns`, `redirect_loop`, `timeout`, `tls`,
|
||||||
|
`not_a_feed`, `site_message`, `connect`, `parse`, `other`; Loki's `| json` names it `error_type`.
|
||||||
|
|
||||||
|
Filter on the text before `| json` where you can (`|= "\"ev\":\"feed_error\""`): it is much
|
||||||
|
cheaper than parsing every line. Lines before 2026-09-29 14:00 UTC are text, not JSON, and
|
||||||
|
`| json | __error__=""` drops them.
|
||||||
|
|
||||||
|
### What Prometheus carries
|
||||||
|
|
||||||
|
The daemon's own state between requests, which no log line or trace holds (#194): ipx serves
|
||||||
|
`/metrics` on `[web] metrics_bind` (production: `192.168.1.130:9187`, LAN only), on a thread and
|
||||||
|
port of its own, from memory alone, and Prometheus scrapes it every 15 s as `job="ipx"`. Kept
|
||||||
|
from 2026-10-11 15:15 UTC. All gauges:
|
||||||
|
|
||||||
|
| metric | what |
|
||||||
|
|---|---|
|
||||||
|
| `ipx_db_connections{state="in_use"\|"idle"}`, `ipx_db_connections_max` | the database pool: in use reaching the max means requests and scans wait for a connection |
|
||||||
|
| `ipx_worker_queue` | commands waiting for the sequential worker (scans, downloads, adds) |
|
||||||
|
| `ipx_event_listeners` | open pages and socket clients on the live-update stream |
|
||||||
|
| `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_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 |
|
||||||
|
| `ipx_last_scan_age_seconds` | since a scan last finished; absent until one has since starting |
|
||||||
|
| `ipx_http_requests_in_flight`, `ipx_scan_feeds_fetching`, `ipx_downloads_active` | work in progress |
|
||||||
|
| `ipx_download_bytes_total` | counter; `rate()` is the download speed |
|
||||||
|
| `ipx_event_queue`, `ipx_events_missed_total` | live updates unread by some listener; counter of those dropped for one that fell behind |
|
||||||
|
| `ipx_deadair_backlog`, `ipx_deadair_busy_seconds` | files of shows with DeadAir on not yet looked at (episodes everyone heard through stay counted); how long it has been on its file |
|
||||||
|
| `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 |
|
||||||
|
| `ipx_feeds`, `ipx_downloads_pending`, `ipx_files_downloaded` | the library, as the healthcheck last counted it (every 30 s) |
|
||||||
|
|
||||||
|
### The checks
|
||||||
|
|
||||||
|
Run these, then read the lines behind whatever stands out. Most of the time is in the reading:
|
||||||
|
a count says something happened, the lines and traces say why.
|
||||||
|
|
||||||
|
1. **Warnings and errors, grouped.** What went wrong, how often, and since when.
|
||||||
|
```
|
||||||
|
$Q metric 'sum by (target, message) (count_over_time({container="iPX"} | json | __error__="" | level=~"WARN|ERROR" [$range]))'
|
||||||
|
```
|
||||||
|
Feed and download failures name the feed in the message; group them in the next check instead.
|
||||||
|
2. **Failing feeds and downloads.**
|
||||||
|
```
|
||||||
|
$Q metric 'sum by (feed, error_type) (count_over_time({container="iPX"} |= "\"ev\":\"feed_error\"" | json | __error__="" [$range]))' 7d
|
||||||
|
$Q metric 'sum by (feed, error_type) (count_over_time({container="iPX"} |= "\"ev\":\"download_error\"" | json | __error__="" [$range]))' 7d
|
||||||
|
```
|
||||||
|
Tell the publisher's problems from ipx's. A 404, 410, DNS failure or 503 from the feed's own
|
||||||
|
server is the publisher (worth saying, since the feed may have moved; one issue for a feed
|
||||||
|
that has been dead for days, not for a 503 once). A parse error on a feed that loads in a
|
||||||
|
browser, a redirect loop ipx should follow, or the same failure on many feeds at once is ipx.
|
||||||
|
3. **Server errors and slow requests.**
|
||||||
|
```
|
||||||
|
$Q metric 'sum by (method, route, status) (count_over_time({container="iPX"} |= "\"target\":\"ipx::http\"" | json | __error__="" | status >= 500 [$range]))'
|
||||||
|
$Q metric 'topk(10, quantile_over_time(0.95, {container="iPX"} |= "\"target\":\"ipx::http\"" | json | __error__="" | route != "" | route != "/api/events" | unwrap duration_ms [$range]) by (method, route))'
|
||||||
|
```
|
||||||
|
Any 5xx is worth a look. 401s are people signing in, not a problem unless one address is
|
||||||
|
hammering. For a slow route, find its traces (check 5) and see which span holds the time.
|
||||||
|
4. **Is the worker keeping up?** Scans should finish regularly, the queue should drain, and the
|
||||||
|
daemon should not be restarting on its own.
|
||||||
|
```
|
||||||
|
$Q metric 'sum(count_over_time({container="iPX"} |= "\"ev\":\"scan_done\"" [$range]))' 6h
|
||||||
|
$Q logs '{container="iPX"} |= "\"ev\":\"status\"" | json | line_format "{{.timestamp}} pending={{.pending}} downloaded={{.downloaded}}"' 6h
|
||||||
|
$Q logs '{container="iPX"} |= "daemon started"' 7d
|
||||||
|
```
|
||||||
|
A `daemon started` not matched by a deploy (see `git log` and the image's build time) is a
|
||||||
|
crash or an OOM kill: check `docker inspect iPX -f '{{.State.OOMKilled}} {{.RestartCount}}'`
|
||||||
|
and the lines just before it. A pending count that only grows means downloads are not
|
||||||
|
keeping up or not running.
|
||||||
|
|
||||||
|
Since 2026-10-02 the daemon sleeps until the next feed is due (at most 10 minutes) instead of
|
||||||
|
scanning every minute (#114), so expect tens of scans in 6 hours, not 360, nearly all with
|
||||||
|
`feeds` above 0; none at all for over 10 minutes means the worker is stuck. And `pending` is
|
||||||
|
the real queue (#113): files a scan will download on its own. Back-catalogue files are
|
||||||
|
`held`, listed but not counted, so it is usually 0 or a handful.
|
||||||
|
5. **Slow and failed traces.** All three services' (filter by `resource.service.name` for one).
|
||||||
|
```
|
||||||
|
$Q traces '{resource.deployment.environment.name="production" && duration > 5s}'
|
||||||
|
$Q traces '{resource.deployment.environment.name="production" && status = error}'
|
||||||
|
$Q trace <id>
|
||||||
|
```
|
||||||
|
Scans (`scan`) are long by nature, since they fetch many feeds one after another: look for one
|
||||||
|
`feed` or `fetch` span holding most of it, or a `download` far slower than its size explains.
|
||||||
|
A web request over a second is worth a look; the trace shows whether the time is in the
|
||||||
|
handler or a scan it waited on.
|
||||||
|
|
||||||
|
Steps are `step` spans named by `of` (#187): an item list's `entries_in`, `sanitize`,
|
||||||
|
`deadair`, `count_in`; the feed list's `catalogue`, `feed_list` and its queries; a `reap`'s
|
||||||
|
`missing_files`, `prune_abandoned_failures`, `reap_candidates`, `clean_directory` (once a day
|
||||||
|
since #193, so a reap is about a second), `trim_artwork`, with what it removed on the `reap`
|
||||||
|
span. DeadAir's work on a file is a `deadair` trace (feed, enclosure, cuts) with `probe`,
|
||||||
|
`silencedetect`, `frame_bytes`, `store_plans`: seconds each, more is a file ffmpeg struggles
|
||||||
|
with. A trace with no name in `$Q traces` started in the page (#190, #192): its root is the tap
|
||||||
|
in the browser, which sends no span, so it holds the requests that tap made (API calls, the
|
||||||
|
playlist and audio of a play, position saves, the page's own report) side by side.
|
||||||
|
|
||||||
|
6. **The daemon's state, from Prometheus.** Pressure and leaks that requests and traces only
|
||||||
|
hint at.
|
||||||
|
```
|
||||||
|
$Q prom 'up{job="ipx"}'
|
||||||
|
$Q prom 'max(max_over_time(ipx_db_connections{job="ipx",state="in_use"}[$range]))' 24h
|
||||||
|
$Q prom 'max(max_over_time(ipx_db_connections_max{job="ipx"}[$range]))' 24h
|
||||||
|
$Q prom 'max(max_over_time(ipx_worker_queue{job="ipx"}[$range]))' 24h
|
||||||
|
$Q prom 'max(max_over_time(ipx_runtime_queue{job="ipx"}[$range]))' 24h
|
||||||
|
$Q prom 'max(resets(ipx_uptime_seconds{job="ipx"}[$range]))' 24h
|
||||||
|
$Q prom 'ipx_process_resident_bytes{job="ipx"} - ipx_process_resident_bytes{job="ipx"} offset $range' 24h
|
||||||
|
$Q prom 'ipx_process_open_fds{job="ipx"} - ipx_process_open_fds{job="ipx"} offset $range' 24h
|
||||||
|
$Q prom 'ipx_runtime_tasks{job="ipx"} - ipx_runtime_tasks{job="ipx"} offset $range' 24h
|
||||||
|
$Q prom 'max(max_over_time(ipx_worker_job_seconds{job="ipx"}[$range])) by (kind)' 24h
|
||||||
|
$Q prom 'max(max_over_time(ipx_last_scan_age_seconds{job="ipx"}[$range]))' 24h
|
||||||
|
$Q prom 'sum by (kind) (increase(ipx_job_seconds_total{job="ipx"}[$range]))' 24h
|
||||||
|
$Q prom 'max(max_over_time(ipx_http_requests_in_flight{job="ipx"}[$range]))' 24h
|
||||||
|
$Q prom 'sum(increase(ipx_events_missed_total{job="ipx"}[$range]))' 24h
|
||||||
|
$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
|
||||||
|
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
|
||||||
|
is a leak (a file or socket not closed, a task never ending); a step after a deploy is not. A
|
||||||
|
reset of the uptime is a restart: match it with a deploy, or it is a crash (check 4).
|
||||||
|
|
||||||
|
The worker: one job over 30 minutes (`ipx_worker_job_seconds`), or a scan last finished over
|
||||||
|
15 minutes ago, is the worker stuck, which the healthcheck does not notice; its trace and the
|
||||||
|
log lines of that time say on what. A kind whose share of `ipx_job_seconds_total` jumps is
|
||||||
|
where the worker's time went (reap was most of it until #193). Web requests in flight near
|
||||||
|
the pool's max mean the pool is what they wait on. Live updates missed mean a page showed stale
|
||||||
|
counts until reloaded. DeadAir over a minute on one file is a file ffmpeg chokes on.
|
||||||
|
|
||||||
|
7. **What the page saw.** The browser's own errors and timings, which no server line shows.
|
||||||
|
```
|
||||||
|
$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
|
||||||
|
```
|
||||||
|
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
|
||||||
|
`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.
|
||||||
|
|
||||||
|
## ipx-web, the page
|
||||||
|
|
||||||
|
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
|
||||||
|
are in ipx's own access log too, with ipx's time; Caddy's `duration` for them adds the hop.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
W='{container="ipx-web"} |= "http.log.access" | json | __error__="" | request_remote_ip != "127.0.0.1"'
|
||||||
|
$Q metric "sum by (status) (count_over_time($W [\$range]))"
|
||||||
|
$Q metric "sum by (request_uri) (count_over_time($W | status >= 500 [\$range]))"
|
||||||
|
$Q metric "topk(10, quantile_over_time(0.95, $W | request_uri !~ \"/(api|media)/.*\" | unwrap duration [\$range]) by (request_uri))"
|
||||||
|
$Q logs '{container="ipx-web"} | json | __error__="" | level=~"warn|error" | msg !~ "admin endpoint disabled|automatic HTTPS is completely disabled|HTTP/[23] skipped.*|exiting; byeee.*"'
|
||||||
|
```
|
||||||
|
|
||||||
|
A 502 is ipx not answering Caddy: a restart (match the deploy) or ipx down; Caddy's error line
|
||||||
|
names it (`dial tcp ...:8099: connect: connection refused`). The page itself (`/`, `/admin`)
|
||||||
|
waits on `/api/page-check` before it is sent, so a slow page is usually a slow page-check: look it
|
||||||
|
up in ipx's access log. 401 and 303 on `/` are people signed out being sent to `/login`, not a
|
||||||
|
problem. Caddy's own notices at each start (no admin endpoint, no HTTPS or HTTP/3, its goodbye on
|
||||||
|
SIGTERM) are by design and filtered out above.
|
||||||
|
|
||||||
|
Each access line carries the request's `traceID` (`| json` gives `traceID`): give it to `$Q trace`
|
||||||
|
to see where the time went, Caddy's span with ipx's inside it. A slow page with ipx's span short is
|
||||||
|
Caddy or the network; a long one is ipx.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$Q traces '{resource.service.name="ipx-web" && duration > 2s}'
|
||||||
|
$Q traces '{resource.service.name="ipx-web" && status = error}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Who reaches it: `request_remote_ip` is `192.168.64.1` for the tunnel and anything else on Tower
|
||||||
|
(Docker's gateway), `192.168.1.130` for Tower itself, a LAN address for the LAN. Caddy passes
|
||||||
|
Cloudflare Access's headers on only from `192.168.64.1` (`TUNNEL_PEER`), and ipx trusts them only
|
||||||
|
from Caddy, with a valid signed token. Testing sign-in from Tower proves nothing about the tunnel
|
||||||
|
(#209): send it from a container on another network.
|
||||||
|
|
||||||
|
## ipx-directory
|
||||||
|
|
||||||
|
It logs a line a request (`target` `ipx_directory::http`: `method`, `path`, `status`,
|
||||||
|
`duration_ms`, `token`, the token's name and never the token), a line a snapshot (`message`
|
||||||
|
`snapshot`: `instance`, `accepted`, `refused_private`, `refused_blocked`, `refused_invalid`), a
|
||||||
|
line a refresher round (`feeds checked`: `checked`), a line a failed feed check (`feed check
|
||||||
|
failed`: `feed`, the directory's id, and `error`), the admin's actions (`feed removed and
|
||||||
|
blocked`, `feed unblocked`, `snapshot dropped`, each with `by`), and `config read again` when its
|
||||||
|
token file changes. It has no metrics. Its traces (#213): a span a request (`http`), a child of
|
||||||
|
ipx's when ipx called with a `traceparent`, and a trace a refresher round (`refresh`, `feeds`)
|
||||||
|
with a `feed` span for each check. Its log lines carry no trace id; find the trace by time or by
|
||||||
|
the feed.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$Q traces '{resource.service.name="ipx-directory" && duration > 2s}'
|
||||||
|
$Q traces '{resource.service.name="ipx-directory" && name="feed" && duration > 10s}'
|
||||||
|
$Q traces '{resource.service.name="ipx" && name="directory_share"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
A slow Directory page in ipx is usually its `http` span in the directory: the trace says whether.
|
||||||
|
A refresher round far over a minute is one publisher's feed holding it, the slow `feed` span.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
D='{container="ipx-directory"} | json | __error__=""'
|
||||||
|
$Q metric "sum by (status, token) (count_over_time({container=\"ipx-directory\"} |= \"ipx_directory::http\" | json | __error__=\"\" [\$range]))"
|
||||||
|
$Q metric "topk(5, max_over_time({container=\"ipx-directory\"} |= \"ipx_directory::http\" | json | __error__=\"\" | unwrap duration_ms [\$range]) by (path))"
|
||||||
|
$Q logs "$D | level=~\"WARN|ERROR\""
|
||||||
|
$Q logs "$D | message=\"snapshot\"" 7d
|
||||||
|
$Q metric "sum(count_over_time($D | message=\"snapshot\" [\$range])) by (instance)"
|
||||||
|
$Q metric "sum by (feed, error) (count_over_time($D | message=\"feed check failed\" [\$range]))" 7d
|
||||||
|
$Q metric "sum(sum_over_time($D | message=\"feeds checked\" | unwrap checked [\$range]))" 24h
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect a snapshot from each server when what it shares changes and every half hour, so about 48 a
|
||||||
|
day each; one every half minute is ipx resending what has not changed (#211, fixed). A burst of
|
||||||
|
401s with an empty `token` is someone without a token trying the public address, expected now and
|
||||||
|
then; a steady stream is worth an issue, since Cloudflare can rate-limit it. A snapshot whose
|
||||||
|
`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
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Tower
|
||||||
|
|
||||||
|
The three share Tower with everything else, so a slow or failing ipx can be the host. The node
|
||||||
|
exporter (`job="node"`) has the history; twelve threads.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$Q prom 'max_over_time(node_load1{job="node"}[$range])' 24h
|
||||||
|
$Q prom 'min_over_time(node_memory_MemAvailable_bytes{job="node"}[$range]) / 1e9' 24h
|
||||||
|
$Q prom 'sum by (mode) (rate(node_cpu_seconds_total{job="node", mode=~"user|system|iowait"}[$range]))' 1h
|
||||||
|
$Q prom 'max by (device) (rate(node_disk_io_time_seconds_total{job="node", device=~"nvme.*|md.*"}[$range]))' 1h
|
||||||
|
```
|
||||||
|
|
||||||
|
For when, step back with `offset`: `max_over_time(node_load1[10m] offset 60m)`. Load far above
|
||||||
|
twelve with little CPU busy is processes waiting, on a disk or on the user shares: on 2026-10-12
|
||||||
|
debug builds linking through `/mnt/user` (shfs, FUSE) took load to 248 with mostly system time and
|
||||||
|
stalled everything on the shares, ipx's downloads included. Available memory near nothing makes
|
||||||
|
it worse. Disk busy near 100% on `nvme1n1` is `/mnt/fast`, appdata and Postgres's data; on `md*`
|
||||||
|
the array behind `/mnt/user`, the downloads. Say what was running at the time if you can tell
|
||||||
|
(`docker stats --no-stream`); a host problem is the user's, not an ipx issue, unless ipx caused it.
|
||||||
|
|
||||||
|
## The monitoring itself
|
||||||
|
|
||||||
|
A quiet dashboard can be a dead exporter. Check what Prometheus scrapes and that each
|
||||||
|
container's lines are reaching Loki:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -s http://192.168.1.130:9090/api/v1/targets | python3 -c 'import json,sys
|
||||||
|
for t in json.load(sys.stdin)["data"]["activeTargets"]: print(t["health"], t["labels"]["job"], t["scrapeUrl"], t.get("lastError","")[:80])'
|
||||||
|
for c in iPX ipx-web ipx-directory; do echo "$c $($Q metric "sum(count_over_time({container=\"$c\"} [1h]))" 1h | tail -1)"; done
|
||||||
|
docker ps -a --filter label=com.docker.compose.project=monitoring --format '{{.Names}} {{.Status}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`ipx` and `node` should be `up`. Every monitoring container should be running; after Docker
|
||||||
|
restarts, one can be left `Exited (255)` though its policy is `unless-stopped` (Prometheus, on
|
||||||
|
2026-10-12, when Docker lost its exit during an overload): `docker start` it and say so. blocky
|
||||||
|
and nut on 192.168.2.21 are another machine, not ipx's; leave them. A container with no lines in
|
||||||
|
an hour while it runs means Alloy is not shipping it; Alloy can take a few minutes to pick up a
|
||||||
|
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.
|
||||||
|
|
||||||
|
## What to do with what you find
|
||||||
|
|
||||||
|
Follow the repository's rules in CLAUDE.md: **every problem found gets a Gitea issue**, with a
|
||||||
|
closed stdin and a timeout on `tea`. Before filing, list the open issues and do not file one
|
||||||
|
twice; comment on the existing issue with the new evidence instead.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
R="--login git.sdf1.net --repo rays/ipx"
|
||||||
|
t() { timeout 30 /src/tea "$@" < /dev/null; }
|
||||||
|
t issues list $R --state open
|
||||||
|
t issues create $R -t "<what is wrong, as the user would notice it>" -L bug -d "<what, where, since when, how often, the query or trace id that shows it>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Put in each issue what would let someone pick it up cold: the LogQL or TraceQL that shows it, a
|
||||||
|
trace id, the first time it was seen and how often. A publisher's dead feed is worth one issue
|
||||||
|
saying so (the user may want to unsubscribe or find its new address); a single 503 is not.
|
||||||
|
|
||||||
|
Finish with a short report to the user, a line for each of ipx-web, ipx, the directory, Tower and
|
||||||
|
the monitoring: what is healthy, what is wrong (with the issue numbers), and anything you could
|
||||||
|
not tell from logs, traces and metrics alone. Do not fix things unless asked; the
|
||||||
|
check is for finding them.
|
||||||
|
|
||||||
|
## When the checks come back empty
|
||||||
|
|
||||||
|
Check that there is data before concluding all is well (the monitoring section's per-container
|
||||||
|
count): iPX should have hundreds of lines an hour or more, ipx-web at least its healthchecks'
|
||||||
|
120, ipx-directory a few dozen. Nothing at all means Alloy is not shipping it, or the container
|
||||||
|
is down.
|
||||||
100
.claude/skills/ipx-prod-check/query.py
Executable file
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Ask production's Loki, Tempo or Prometheus a question, from anywhere that can run docker on Tower.
|
||||||
|
|
||||||
|
query.py logs '<LogQL log query>' [since] lines, newest first
|
||||||
|
query.py metric '<LogQL metric query>' [since] one value per series; $range is `since`
|
||||||
|
query.py traces '<TraceQL query>' [since] matching traces, slowest first
|
||||||
|
query.py trace <trace id> one trace's spans, as a tree
|
||||||
|
query.py prom '<PromQL query>' [since] one value per series, now; $range is `since`
|
||||||
|
|
||||||
|
`since` is 1h, 24h, 7d and the like (default 24h). Loki and Tempo publish no query port on the
|
||||||
|
host, so each call runs a throwaway alpine container on the monitoring project's network.
|
||||||
|
"""
|
||||||
|
import json, subprocess, sys, time, urllib.parse
|
||||||
|
|
||||||
|
NET = "monitoring_default"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(url):
|
||||||
|
out = subprocess.run(["docker", "run", "--rm", "--network", NET, "alpine", "wget", "-qO-", url],
|
||||||
|
capture_output=True, text=True, timeout=120)
|
||||||
|
if out.returncode:
|
||||||
|
sys.exit(f"query failed: {out.stderr.strip() or out.stdout.strip()}\n{url}")
|
||||||
|
return json.loads(out.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def seconds(since):
|
||||||
|
return int(since[:-1]) * {"m": 60, "h": 3600, "d": 86400}[since[-1]]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
kind, q = sys.argv[1], sys.argv[2]
|
||||||
|
since = sys.argv[3] if len(sys.argv) > 3 else "24h"
|
||||||
|
now = time.time()
|
||||||
|
start = now - seconds(since)
|
||||||
|
enc = urllib.parse.quote
|
||||||
|
if kind == "logs":
|
||||||
|
r = fetch(f"http://loki:3100/loki/api/v1/query_range?query={enc(q)}&limit=500"
|
||||||
|
f"&start={int(start * 1e9)}&end={int(now * 1e9)}&direction=backward")
|
||||||
|
lines = [(ts, line) for s in r["data"]["result"] for ts, line in s["values"]]
|
||||||
|
for ts, line in sorted(lines, reverse=True):
|
||||||
|
print(line)
|
||||||
|
print(f"-- {len(lines)} line(s){' (limit reached)' if len(lines) >= 500 else ''}", file=sys.stderr)
|
||||||
|
elif kind == "metric":
|
||||||
|
r = fetch(f"http://loki:3100/loki/api/v1/query?query={enc(q.replace('$range', since))}&time={int(now * 1e9)}")
|
||||||
|
rows = sorted(r["data"]["result"], key=lambda s: -float(s["value"][1]))
|
||||||
|
for s in rows:
|
||||||
|
labels = {k: v for k, v in s["metric"].items() if k not in ("container", "compose_project", "service_name")}
|
||||||
|
print(f"{s['value'][1]:>12} {json.dumps(labels) if labels else ''}")
|
||||||
|
print(f"-- {len(rows)} series", file=sys.stderr)
|
||||||
|
elif kind == "traces":
|
||||||
|
r = fetch(f"http://tempo:3200/api/search?q={enc(q)}&start={int(start)}&end={int(now)}&limit=100")
|
||||||
|
traces = sorted(r.get("traces", []), key=lambda t: -t.get("durationMs", 0))
|
||||||
|
for t in traces:
|
||||||
|
when = time.strftime("%m-%d %H:%M:%S", time.gmtime(int(t["startTimeUnixNano"]) / 1e9))
|
||||||
|
# No root name: its root span is a tap in the page, which the browser never sends.
|
||||||
|
name = t.get("rootTraceName") or "(from the page)"
|
||||||
|
print(f"{t.get('durationMs', 0):>8}ms {when}Z {t['traceID']} {name}")
|
||||||
|
print(f"-- {len(traces)} trace(s)", file=sys.stderr)
|
||||||
|
elif kind == "prom":
|
||||||
|
# ipx's /metrics (#194), scraped as job ipx; through the network like the others.
|
||||||
|
r = fetch(f"http://prometheus:9090/api/v1/query?query={enc(q.replace('$range', since))}&time={now}")
|
||||||
|
rows = sorted(r["data"]["result"], key=lambda s: -float(s["value"][1]))
|
||||||
|
for s in rows:
|
||||||
|
labels = {k: v for k, v in s["metric"].items() if k not in ("job", "instance")}
|
||||||
|
print(f"{float(s['value'][1]):>14g} {json.dumps(labels) if labels else ''}")
|
||||||
|
print(f"-- {len(rows)} series", file=sys.stderr)
|
||||||
|
elif kind == "trace":
|
||||||
|
r = fetch(f"http://tempo:3200/api/traces/{q}")
|
||||||
|
spans = [sp for b in r.get("batches", r.get("resourceSpans", []))
|
||||||
|
for ss in b.get("scopeSpans", b.get("instrumentationLibrarySpans", [])) for sp in ss["spans"]]
|
||||||
|
kids = {}
|
||||||
|
for sp in spans:
|
||||||
|
kids.setdefault(sp.get("parentSpanId", ""), []).append(sp)
|
||||||
|
|
||||||
|
def show(sp, depth):
|
||||||
|
ms = (int(sp["endTimeUnixNano"]) - int(sp["startTimeUnixNano"])) / 1e6
|
||||||
|
attrs = {a["key"]: next(iter(a["value"].values()), None) for a in sp.get("attributes", [])
|
||||||
|
if not a["key"].startswith(("code.", "thread.")) and a["key"] not in ("busy_ns", "idle_ns", "target")}
|
||||||
|
err = " ERROR" if sp.get("status", {}).get("code") in (2, "STATUS_CODE_ERROR") else ""
|
||||||
|
print(f"{' ' * depth}{sp['name']} {ms:.0f}ms{err} {json.dumps(attrs) if attrs else ''}")
|
||||||
|
for e in sp.get("events", []):
|
||||||
|
msg = next((a["value"].get("stringValue") for a in e.get("attributes", []) if a["key"] == "message"), e.get("name"))
|
||||||
|
# A scan logs a skip for every feed not due; they bury what happened.
|
||||||
|
if '"ev":"feed_skip"' in (msg or ""):
|
||||||
|
continue
|
||||||
|
print(f"{' ' * depth} - {msg}")
|
||||||
|
for k in sorted(kids.get(sp["spanId"], []), key=lambda s: int(s["startTimeUnixNano"])):
|
||||||
|
show(k, depth + 1)
|
||||||
|
|
||||||
|
ids = {sp["spanId"] for sp in spans}
|
||||||
|
for root in [sp for sp in spans if sp.get("parentSpanId", "") not in ids]:
|
||||||
|
show(root, 0)
|
||||||
|
else:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
4
.gitignore
vendored
@@ -3,3 +3,7 @@
|
|||||||
/test-results
|
/test-results
|
||||||
/playwright-report
|
/playwright-report
|
||||||
/web/dist
|
/web/dist
|
||||||
|
|
||||||
|
.claude/settings.local.json
|
||||||
|
__pycache__/
|
||||||
|
site/dist/
|
||||||
|
|||||||
176
CLAUDE.md
@@ -7,18 +7,22 @@ made here.
|
|||||||
## Where things are
|
## Where things are
|
||||||
|
|
||||||
Production is the `iPX` container on Tower (192.168.1.130), the `ipx` service of the
|
Production is the `iPX` container on Tower (192.168.1.130), the `ipx` service of the
|
||||||
Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That file is what runs;
|
Arcane project `ipx`: `/mnt/fast/arcane/projects/ipx/compose.yaml`. That file is what runs, and
|
||||||
`docker-compose.yml` in this repo is a copy, and editing it changes nothing in production.
|
the containers of the split (#207) join it there. `docker-compose.yml` in this repo is a sample for
|
||||||
|
others, ipx alone on SQLite; editing it changes nothing in production. Until 2026-10-12 ipx was a
|
||||||
|
service of the `content` project, beside audiobookshelf, immich and Plex (#208).
|
||||||
|
|
||||||
| | Host | In the container |
|
| | Host | In the container |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Image | `192.168.1.130:5000/ipx:latest` | |
|
| Image | `192.168.1.130:5000/ipx:latest` | |
|
||||||
| Config | `/mnt/fast/appdata/ipx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
|
| Config | `/mnt/fast/appdata/ipx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
|
||||||
| 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/content/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 | |
|
| 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` |
|
| 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` |
|
| Downloads | `/mnt/user/ipx/downloads` | `/downloads` |
|
||||||
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
| 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` |
|
||||||
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
|
| 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`) |
|
||||||
|
|
||||||
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipx/issues, not in a
|
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipx/issues, not in a
|
||||||
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipx`.
|
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipx`.
|
||||||
@@ -47,24 +51,54 @@ Deploying a change is: build and push the image, then pull it and recreate the c
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker buildx build --tag 192.168.1.130:5000/ipx:latest . --push
|
docker buildx build --tag 192.168.1.130:5000/ipx:latest . --push
|
||||||
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipx
|
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx
|
||||||
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipx
|
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx
|
||||||
docker logs --tail 20 iPX
|
docker logs --tail 20 iPX
|
||||||
```
|
```
|
||||||
|
|
||||||
**Name the service.** A bare `up -d` recreates every container in `content`, beets and immich
|
The directory is the same, with its own image:
|
||||||
included. Run `pull` before `up`, because `up` reuses whatever `latest` the host already has.
|
|
||||||
|
```sh
|
||||||
|
docker buildx build -f Dockerfile.directory --tag 192.168.1.130:5000/ipx-directory:latest . --push
|
||||||
|
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-directory
|
||||||
|
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-directory
|
||||||
|
```
|
||||||
|
|
||||||
|
And the page, `ipx-web`, the same again; a change to `web/` alone needs only this one:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker buildx build -f Dockerfile.web --tag 192.168.1.130:5000/ipx-web:latest . --push
|
||||||
|
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-web
|
||||||
|
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-web
|
||||||
|
```
|
||||||
|
|
||||||
|
The home page (`site/`, #216) is not on Tower. `sh site/build.sh` puts it in `site/dist` with the
|
||||||
|
typeface, logo and icon from `web/`, and it goes to the Cloudflare Pages project `ipxreader` by
|
||||||
|
direct upload, since there is no wrangler login here: hash each file as wrangler does (BLAKE3 of
|
||||||
|
its base64 contents plus its extension, the first 32 hex digits, with the `blake3-wasm` package),
|
||||||
|
ask the project for an upload token (`GET .../pages/projects/ipxreader/upload-token`), send the
|
||||||
|
files to `/pages/assets/upload` with that token, then create a deployment
|
||||||
|
(`POST .../pages/projects/ipxreader/deployments`) with the manifest of paths and hashes and
|
||||||
|
`_headers`. Its screenshots (`site/shots`) come from a scratch iPX on public feeds, never
|
||||||
|
production's, which would show whose subscriptions are whose.
|
||||||
|
|
||||||
|
Testing sign-in from Tower itself proves nothing about the tunnel: Tower's own requests reach the
|
||||||
|
published port as `192.168.1.130`, and Caddy rightly strips their `Cf-Access-*`. Send them from a
|
||||||
|
container on another network, as cloudflared's are (#209).
|
||||||
|
|
||||||
|
**Name the service.** A bare `up -d` recreates every container in the project, which once the
|
||||||
|
split lands means the frontend and directory too. Run `pull` before `up`, because `up` reuses whatever `latest` the host already has.
|
||||||
|
|
||||||
**A build that fails with `429 Too Many Requests` on a base image** is Docker Hub rate-limiting
|
**A build that fails with `429 Too Many Requests` on a base image** is Docker Hub rate-limiting
|
||||||
this host. There is no Docker Hub login here, and the build asks about `debian:bookworm-slim` and
|
this host. There is no Docker Hub login here, and the build asks about `debian:trixie-slim` and
|
||||||
`rust:1-slim-bookworm` every time unless they are already stored locally. Pull them from Google's
|
`rust:1-slim-trixie` every time unless they are already stored locally. Pull them from Google's
|
||||||
mirror and tag them; the build then uses the local copies without asking Docker Hub:
|
mirror and tag them; the build then uses the local copies without asking Docker Hub:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker pull mirror.gcr.io/library/debian:bookworm-slim
|
docker pull mirror.gcr.io/library/debian:trixie-slim
|
||||||
docker tag mirror.gcr.io/library/debian:bookworm-slim debian:bookworm-slim
|
docker tag mirror.gcr.io/library/debian:trixie-slim debian:trixie-slim
|
||||||
docker pull mirror.gcr.io/library/rust:1-slim-bookworm
|
docker pull mirror.gcr.io/library/rust:1-slim-trixie
|
||||||
docker tag mirror.gcr.io/library/rust:1-slim-bookworm rust:1-slim-bookworm
|
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.
|
Run those again now and then, or the local copies go stale.
|
||||||
@@ -87,13 +121,23 @@ running the command and kills the session (exit 144), which has happened more th
|
|||||||
There are three pages: the app (`web/index.html`), the admin page (`web/admin.html`, sent to
|
There are three pages: the app (`web/index.html`), the admin page (`web/admin.html`, sent to
|
||||||
admins only) and sign-in (`web/login.html`). The app and admin pages share one stylesheet,
|
admins only) and sign-in (`web/login.html`). The app and admin pages share one stylesheet,
|
||||||
`web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mjs` lists which files
|
`web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mjs` lists which files
|
||||||
make up each page's script. `build.rs` runs
|
make up each page's script. The page is its own image, **`ipx-web`** (#203): `web/build.mjs` uses
|
||||||
`web/build.mjs`, which uses swc to strip the types and minify the script into `app.js` (and
|
swc to strip the types and minify each script (`app.js`, `admin.js`, `login.js`), minifies the
|
||||||
`login.js`), and minifies the page, and the results are `include_str!`d into the binary. The page
|
pages, and puts them with the icons and typeface in `web/dist`, which Caddy serves
|
||||||
loads its script as `/app.js?v=<hash of its contents>`, and `/app.css` the same way: the page is
|
(`web/Caddyfile`, `Dockerfile.web`), passing `/api` and `/media` to ipx on the same origin. ipx
|
||||||
served `no-cache` and the script and stylesheet `immutable`, so a browser keeps them until a
|
serves no page. The page loads its script as `/app.js?v=<hash of its contents>`, and `/app.css`
|
||||||
deploy changes them and their names. So **every page change needs a
|
and `/boot.js` the same way: the page is served `no-cache` and those `immutable`, so a browser
|
||||||
rebuild** before it is visible, and building needs node and `npm ci` run once.
|
keeps them until a deploy changes them and their names. So **every page change needs ipx-web
|
||||||
|
rebuilt and deployed**, not ipx, and building needs node and `npm ci` run once.
|
||||||
|
|
||||||
|
Caddy asks ipx before it serves a page (`forward_auth`): `/api/page-check` sends a browser that is
|
||||||
|
not signed in to `/login` and anyone but an admin away from `/admin` (and refuses them
|
||||||
|
`/admin.js`); `/api/login-check` sends someone Cloudflare Access vouches for past the form; a page
|
||||||
|
asked for with `?token=` goes to `/api/signin`, which sets the cookie. The page is the same file
|
||||||
|
for everyone, so what ipx used to write into it per person is the page's own job now:
|
||||||
|
`web/boot.js`, in `<head>`, puts the theme from its cookie and an admin's link from the last visit
|
||||||
|
on the page before the first frame, and `/api/me` brings the rest (version, the account's old
|
||||||
|
theme).
|
||||||
|
|
||||||
The files in `web/src` are not modules. They are one script split up, concatenated in the order
|
The files in `web/src` are not modules. They are one script split up, concatenated in the order
|
||||||
`web/build.mjs` lists them, sharing one top-level scope as the single inline script did; a new
|
`web/build.mjs` lists them, sharing one top-level scope as the single inline script did; a new
|
||||||
@@ -124,7 +168,8 @@ npx tsc -p . # type-checks web/src
|
|||||||
node tests/page-smoke.js
|
node tests/page-smoke.js
|
||||||
node tests/native-bridge.js # the page hands playback to a native shell
|
node tests/native-bridge.js # the page hands playback to a native shell
|
||||||
node tests/contrast.js # every theme's palette against WCAG AA
|
node tests/contrast.js # every theme's palette against WCAG AA
|
||||||
npx playwright test # 40 browser tests against a real daemon on fixture feeds
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
Things about the browser suite that have cost time:
|
Things about the browser suite that have cost time:
|
||||||
@@ -141,6 +186,31 @@ Things about the browser suite that have cost time:
|
|||||||
* `webServer` starts **before** `globalSetup`, which is why the fixture config is written at
|
* `webServer` starts **before** `globalSetup`, which is why the fixture config is written at
|
||||||
config-load time instead.
|
config-load time instead.
|
||||||
|
|
||||||
|
The load tests (`tests/load`, issue #133) are for what one browser cannot show: twenty-five people
|
||||||
|
browsing at once, a hundred players saving positions while a scan writes, fifty listeners
|
||||||
|
seeking through files, a flood of wrong passwords. Things about them:
|
||||||
|
|
||||||
|
* They need **k6**, which `/src/install.sh` installs from k6's own signed apt repository, and they
|
||||||
|
build and run a **release** binary: Argon2 in a debug build takes about a second a sign-in,
|
||||||
|
which would measure the build.
|
||||||
|
* `run.js` serves the feeds itself, generated, and starts **its own daemon** under
|
||||||
|
`/tmp/ipx-load`, wiped each run, on ports 8793 and 8794, so it can run beside the browser suite.
|
||||||
|
It stops that daemon by its PID. People sign in by the `X-Load-User` header, trusted from
|
||||||
|
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.
|
||||||
|
* 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
|
||||||
|
`ipodderx_test` on the `databases` project's server, production's, as `ipodderx`, taken from
|
||||||
|
`ipx.env`; the harness refuses a database whose name does not end in `_test`. Postgres's
|
||||||
|
numbers are what production would see, and a run loads the server production's database is on,
|
||||||
|
so not while someone is using iPX in earnest. Each budget has a value for each database.
|
||||||
|
* The Rust tests run on Postgres with the same URL: `. /src/.envrc && cargo test`. Each test
|
||||||
|
makes a schema `ipxt_<pid>_<n>`, and the next run drops those an earlier one left.
|
||||||
|
|
||||||
Non-trivial logic leaves one runnable check behind. Pure functions (`merge_policy`, `pick`,
|
Non-trivial logic leaves one runnable check behind. Pure functions (`merge_policy`, `pick`,
|
||||||
`matches_keywords`, `parse_interval`) are the easiest place to put it.
|
`matches_keywords`, `parse_interval`) are the easiest place to put it.
|
||||||
|
|
||||||
@@ -170,15 +240,20 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
|
|||||||
watch the shutdown channel itself; the daemon ignored SIGTERM for exactly this reason.
|
watch the shutdown channel itself; the daemon ignored SIGTERM for exactly this reason.
|
||||||
* Only one daemon per socket. Removing the socket file defeats the guard and you get two daemons
|
* Only one daemon per socket. Removing the socket file defeats the guard and you get two daemons
|
||||||
fighting over the database, with the stale one still holding the port.
|
fighting over the database, with the stale one still holding the port.
|
||||||
|
* **The Grafana dashboard reads the log's fields** (`grafana/dashboard.py`). Production logs JSON
|
||||||
|
(`IPX_LOG_FORMAT=json`); the access log's `method`, `path`, `route`, `status`, `duration_ms` and
|
||||||
|
the events' `ev`, `feed`, `new`, `bytes`, `msg` (`log_event` in ipc.rs) are what the panels query.
|
||||||
|
Rename one and its panels go blank without an error; regenerate the dashboard to match.
|
||||||
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
|
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
|
||||||
different task. `ipx status` checks the control socket and the database; to see the worker
|
different task. `ipx status` checks the control socket and the database; to see the worker
|
||||||
getting through its jobs, watch for `scan complete` in the log.
|
getting through its jobs, watch for `scan complete` in the log.
|
||||||
* **The database goes through SeaORM, and the entities in `src/entity.rs` are the schema.**
|
* **The database goes through SeaORM, and the entities in `crates/ipx/src/entity.rs` are the schema.**
|
||||||
`Db::open` creates any missing table or index from them (`create_missing`), on every `ipx`
|
`Db::open` creates any missing table or index from them (`create_missing`), on every `ipx`
|
||||||
command, the healthcheck's `ipx status` included, so it must never write when nothing is
|
command, the healthcheck's `ipx status` included, so it must never write when nothing is
|
||||||
missing: SeaORM's experimental schema sync dropped and remade an index on every open, the
|
missing: SeaORM's experimental schema sync dropped and remade an index on every open, the
|
||||||
write lock that took made `ipx status` time out behind a busy daemon, and it was removed for
|
write lock that took made `ipx status` time out behind a busy daemon, and it was removed for
|
||||||
it. A new column on an existing table needs its own `ALTER`; nothing adds one for you.
|
it. A new column on an existing table needs its own `ALTER`; nothing adds one for you. Look for
|
||||||
|
the column first and alter only when it is missing, as `feeds.description` does.
|
||||||
* **SQL written by hand in `db.rs` has to run on SQLite and Postgres both** (issue #18): `$1`
|
* **SQL written by hand in `db.rs` has to run on SQLite and Postgres both** (issue #18): `$1`
|
||||||
parameters, bound only if used; `ON CONFLICT`, not `INSERT OR IGNORE`; yes/no columns tested
|
parameters, bound only if used; `ON CONFLICT`, not `INSERT OR IGNORE`; yes/no columns tested
|
||||||
as themselves (`NOT coalesce(s.read, false)`) and written as `true`/`false`, never compared to
|
as themselves (`NOT coalesce(s.read, false)`) and written as `true`/`false`, never compared to
|
||||||
@@ -190,16 +265,55 @@ Comments explain **why**, not what. If a line looks odd, the comment says what w
|
|||||||
it. No emoji, no exclamation marks, no "obviously". Prose in the UI and docs is plain English and
|
it. No emoji, no exclamation marks, no "obviously". Prose in the UI and docs is plain English and
|
||||||
addressed to the person using it.
|
addressed to the person using it.
|
||||||
|
|
||||||
Every change gets one line under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in its
|
Every change gets one line under `## [Unreleased]` in the changelog of the service it changes, in
|
||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
|
its [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
|
||||||
Removed, Fixed or Security. Say it the way someone using ipx would notice it. When there is more to
|
Removed, Fixed or Security. One changelog a service: [crates/ipx/CHANGELOG.md](crates/ipx/CHANGELOG.md)
|
||||||
|
(the daemon and its API; until 0.12.0 everything), [web/CHANGELOG.md](web/CHANGELOG.md) (the page
|
||||||
|
and ipx-web) and [crates/directory/CHANGELOG.md](crates/directory/CHANGELOG.md) (ipx-directory). A
|
||||||
|
change to more than one gets a line in each, each saying what that service does differently. The
|
||||||
|
monitoring (the Grafana dashboard, the prod-check skill) and the repository's own tooling are not
|
||||||
|
a service anyone uses and get no line. Say it the way someone using ipx would notice it. When there is more to
|
||||||
say, such as what was wrong before or what it cost to find out, it goes in the commit message's
|
say, such as what was wrong before or what it cost to find out, it goes in the commit message's
|
||||||
body, where `git log` and `git blame` find it beside the change. (There was a long-form
|
body, where `git log` and `git blame` find it beside the change. (There was a long-form
|
||||||
`docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.)
|
`docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.)
|
||||||
|
|
||||||
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty
|
Each service has its own version and is released on its own, when its changelog has something to
|
||||||
`[Unreleased]` above it, bump `version` in `Cargo.toml`, tag the commit `vX.Y.Z`, and update the
|
release:
|
||||||
compare links at the bottom of the changelog.
|
|
||||||
|
| service | version in | changelog | tag |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ipx | `crates/ipx/Cargo.toml` | `crates/ipx/CHANGELOG.md` | `vX.Y.Z`, as every release before the split |
|
||||||
|
| ipx-directory | `crates/directory/Cargo.toml` | `crates/directory/CHANGELOG.md` | `directory-vX.Y.Z` |
|
||||||
|
| ipx-web, the page | `package.json` | `web/CHANGELOG.md` | `web-vX.Y.Z` |
|
||||||
|
|
||||||
|
`crates/feed` is a library the two Rust services build in, not released (`0.0.0`); a change to it
|
||||||
|
is a line in the changelog of each service it changes. The page shows ipx's version and its own in
|
||||||
|
the logo's tooltip; the directory logs its own at start; each binary answers `--version`.
|
||||||
|
|
||||||
|
Cutting a release of one: first read its `[Unreleased]` against its last release, not line by
|
||||||
|
line, and rewrite it as what changed between the two. Lines go in as the work happens, so they
|
||||||
|
record the way there: a feature added and then reworked in the same release is one line saying
|
||||||
|
what it is now, under Added; something added and taken out again before the release, or a fix for
|
||||||
|
a bug no release ever had, is not there at all; and "instead of" and "no longer" compare with the
|
||||||
|
last release, not with last week. Issue numbers stay, all of them, on the line that survives. Then
|
||||||
|
rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty `[Unreleased]` above it,
|
||||||
|
set the service's version to `X.Y.Z` (dropping `-dev`), tag the commit with the service's tag, and
|
||||||
|
update the compare links at the bottom of its changelog. Push the commit and the tag, build and
|
||||||
|
deploy the service's image, then `sh scripts/release.sh ipx|directory|web` makes the Gitea
|
||||||
|
release: that version's changelog section as its notes, and a tarball of what the image runs (the
|
||||||
|
binary, or the page's files with the Caddyfile) attached, checked to be that version. Then, in the
|
||||||
|
next commit, set its version
|
||||||
|
to the next patch with `-dev` (after 0.9.0, `0.9.1-dev`), so a build between releases says so
|
||||||
|
instead of claiming to be the last release. The release that follows can still be a minor or major
|
||||||
|
one; `-dev` only says the work comes after `X.Y.Z`. A service never released is at `0.1.0-dev`,
|
||||||
|
working towards its first release, `0.1.0`.
|
||||||
|
|
||||||
|
**Traces first, logs second.** Anything that does work (a service, a handler, a job, a call to
|
||||||
|
another service) gets spans, and passes `traceparent` on to whatever it calls, so one trace in
|
||||||
|
Tempo follows a request or a job across ipx-web, ipx and the directory. A trace shows where the
|
||||||
|
time went and what called what; a log line only that something happened. Log what a trace cannot
|
||||||
|
carry: events worth counting on the dashboard, warnings and errors. A new service or route without
|
||||||
|
traces is not finished.
|
||||||
|
|
||||||
Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g.
|
Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g.
|
||||||
`// ponytail: global connection mutex, move to a pool if feed count makes it contend`.
|
`// ponytail: global connection mutex, move to a pool if feed count makes it contend`.
|
||||||
|
|||||||
172
Cargo.lock
generated
@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.9.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -1830,8 +1830,11 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"ipx-directory",
|
||||||
|
"ipx-feed",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"librqbit",
|
"librqbit",
|
||||||
|
"opentelemetry",
|
||||||
"opml",
|
"opml",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"quick-xml 0.42.0",
|
"quick-xml 0.42.0",
|
||||||
@@ -1840,15 +1843,61 @@ dependencies = [
|
|||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2 0.10.9",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http 0.7.1",
|
"tower-http 0.7.1",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tracing-opentelemetry",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ipx-directory"
|
||||||
|
version = "1.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"ammonia",
|
||||||
|
"anyhow",
|
||||||
|
"axum",
|
||||||
|
"clap",
|
||||||
|
"ipx-feed",
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry-otlp",
|
||||||
|
"opentelemetry_sdk",
|
||||||
|
"percent-encoding",
|
||||||
|
"reqwest",
|
||||||
|
"sea-orm",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2 0.10.9",
|
||||||
|
"tokio",
|
||||||
|
"toml",
|
||||||
|
"tower",
|
||||||
|
"tracing",
|
||||||
|
"tracing-opentelemetry",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ipx-feed"
|
||||||
|
version = "0.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"atom_syndication",
|
||||||
|
"chrono",
|
||||||
|
"opml",
|
||||||
|
"quick-xml 0.42.0",
|
||||||
|
"reqwest",
|
||||||
|
"rss",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
@@ -2710,6 +2759,76 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry"
|
||||||
|
version = "0.33.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6cdb0b1b267eb9db3331b434ed9ddab10d50e280a9adf9d13e5233e2002b61b5"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"js-sys",
|
||||||
|
"pin-project-lite",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-http"
|
||||||
|
version = "0.33.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee2c3625b8aa04209f7e01e513bc8044da9687fa38a9eacf59628ca5f3b87300"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"opentelemetry",
|
||||||
|
"reqwest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-otlp"
|
||||||
|
version = "0.33.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "699a67345e21962a231955b9059a157e428b219fa5df8efe11f08346fb23a344"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"httpdate",
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry-http",
|
||||||
|
"opentelemetry-proto",
|
||||||
|
"opentelemetry_sdk",
|
||||||
|
"prost",
|
||||||
|
"reqwest",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-proto"
|
||||||
|
version = "0.33.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "25da1ac11a0aeccf38d7f77ee0348715adaf8340f65ad46c94a02c6b20e2f65d"
|
||||||
|
dependencies = [
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry_sdk",
|
||||||
|
"prost",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry_sdk"
|
||||||
|
version = "0.33.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb39533d9d1c912123efd7d41d7e0c29d16917b60ce15b4c8d87cb1af7f67520"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-util",
|
||||||
|
"opentelemetry",
|
||||||
|
"percent-encoding",
|
||||||
|
"portable-atomic",
|
||||||
|
"rand 0.9.5",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opml"
|
name = "opml"
|
||||||
version = "1.1.6"
|
version = "1.1.6"
|
||||||
@@ -2925,6 +3044,29 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost"
|
||||||
|
version = "0.14.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"prost-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost-derive"
|
||||||
|
version = "0.14.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"itertools 0.14.0",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quanta"
|
name = "quanta"
|
||||||
version = "0.12.6"
|
version = "0.12.6"
|
||||||
@@ -3225,6 +3367,7 @@ dependencies = [
|
|||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
"h2",
|
||||||
@@ -4563,6 +4706,30 @@ dependencies = [
|
|||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-opentelemetry"
|
||||||
|
version = "0.34.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0a904802a1b902f43638b677ff2a650847e3b4404101b6c586d648e8c1e3e8fe"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"opentelemetry",
|
||||||
|
"tracing",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-serde"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-subscriber"
|
name = "tracing-subscriber"
|
||||||
version = "0.3.23"
|
version = "0.3.23"
|
||||||
@@ -4573,12 +4740,15 @@ dependencies = [
|
|||||||
"nu-ansi-term",
|
"nu-ansi-term",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"regex-automata",
|
"regex-automata",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sharded-slab",
|
"sharded-slab",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thread_local",
|
"thread_local",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
"tracing-log",
|
"tracing-log",
|
||||||
|
"tracing-serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
23
Cargo.toml
@@ -1,9 +1,15 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "ipx"
|
members = ["crates/ipx", "crates/feed", "crates/directory"]
|
||||||
version = "0.9.0"
|
resolver = "3"
|
||||||
|
|
||||||
|
# Each service has its own version, in its own Cargo.toml (ipx, ipx-directory) or package.json
|
||||||
|
# (the page), released and tagged on its own; see CLAUDE.md.
|
||||||
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[workspace.dependencies]
|
||||||
|
ipx-feed = { path = "crates/feed" }
|
||||||
|
ipx-directory = { path = "crates/directory" }
|
||||||
ammonia = "4.1.4"
|
ammonia = "4.1.4"
|
||||||
anyhow = "1.0.104"
|
anyhow = "1.0.104"
|
||||||
argon2 = "0.6.0"
|
argon2 = "0.6.0"
|
||||||
@@ -14,6 +20,9 @@ clap = { version = "4.6.6", features = ["derive"] }
|
|||||||
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
||||||
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
|
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"] }
|
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_sdk = { version = "0.33", default-features = false, features = ["trace"] }
|
||||||
opml = "1.1.6"
|
opml = "1.1.6"
|
||||||
percent-encoding = "2.3.2"
|
percent-encoding = "2.3.2"
|
||||||
quick-xml = { version = "0.42.0", features = ["escape-html"] }
|
quick-xml = { version = "0.42.0", features = ["escape-html"] }
|
||||||
@@ -22,10 +31,12 @@ 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"] }
|
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"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.151"
|
serde_json = "1.0.151"
|
||||||
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }
|
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"
|
toml = "1.1.5"
|
||||||
tower = { version = "0.5.3", features = ["util"] }
|
tower = { version = "0.5.3", features = ["util"] }
|
||||||
tower-http = { version = "0.7.1", features = ["fs"] }
|
tower-http = { version = "0.7.1", features = ["fs"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-opentelemetry = { version = "0.34", default-features = false }
|
||||||
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
|
||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
|
|||||||
36
Dockerfile
@@ -1,31 +1,33 @@
|
|||||||
# Build. rusqlite is bundled (compiles SQLite from source) and librqbit needs a C
|
# Build. rusqlite is bundled (compiles SQLite from source) and librqbit needs a C
|
||||||
# toolchain, so the builder needs cc. TLS is rustls throughout, so no OpenSSL headers.
|
# toolchain, so the builder needs cc. TLS is rustls throughout, so no OpenSSL headers.
|
||||||
# build.rs builds the web pages from TypeScript with swc, which needs node.
|
# The pages are ipx-web's (Dockerfile.web), so no node here (#203).
|
||||||
FROM rust:1-slim-bookworm AS build
|
FROM rust:1-slim-trixie AS build
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential nodejs npm \
|
build-essential \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# swc only: Playwright and TypeScript are for testing and type-checking, not for building.
|
# Dependencies first, so editing the source does not rebuild librqbit every time: each crate of
|
||||||
COPY package.json package-lock.json ./
|
# the workspace as an empty stub, its manifest real.
|
||||||
RUN npm ci --omit=dev
|
|
||||||
|
|
||||||
# Dependencies first, so editing the source does not rebuild librqbit every time.
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
RUN mkdir src && echo 'fn main(){}' > src/main.rs \
|
COPY crates/ipx/Cargo.toml crates/ipx/
|
||||||
&& cargo build --release --locked \
|
COPY crates/feed/Cargo.toml crates/feed/
|
||||||
&& rm -rf src
|
COPY crates/directory/Cargo.toml crates/directory/
|
||||||
|
RUN mkdir -p crates/ipx/src crates/feed/src crates/directory/src \
|
||||||
|
&& echo 'fn main(){}' > crates/ipx/src/main.rs && touch crates/feed/src/lib.rs \
|
||||||
|
&& echo 'fn main(){}' > crates/directory/src/main.rs && touch crates/directory/src/lib.rs \
|
||||||
|
&& cargo build --release --locked -p ipx \
|
||||||
|
&& rm -rf crates/ipx/src crates/feed/src crates/directory/src
|
||||||
|
|
||||||
COPY build.rs ./
|
COPY crates ./crates
|
||||||
COPY src ./src
|
|
||||||
COPY web ./web
|
|
||||||
# cargo skips a rebuild if mtimes look untouched; make sure it does not.
|
# cargo skips a rebuild if mtimes look untouched; make sure it does not.
|
||||||
RUN touch src/main.rs && cargo build --release --locked
|
RUN touch crates/ipx/src/main.rs crates/feed/src/lib.rs && cargo build --release --locked -p ipx
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:trixie-slim
|
||||||
|
# ffmpeg is DeadAir's (#171): it finds the silences and copies the frames kept. Debian's adds
|
||||||
|
# about 480 MB, once: the layer stays cached until the base image changes.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates gosu \
|
ca-certificates gosu ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=build /src/target/release/ipx /usr/local/bin/ipx
|
COPY --from=build /src/target/release/ipx /usr/local/bin/ipx
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
|
|||||||
25
Dockerfile.directory
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# ipx-directory (#204). Built from the repo's root: docker buildx build -f Dockerfile.directory .
|
||||||
|
# No node here: only ipx's own build runs web/build.mjs.
|
||||||
|
FROM rust:1-slim-trixie AS build
|
||||||
|
# SQLite is compiled from source for sqlx, which needs a C toolchain.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /src
|
||||||
|
# Every member of the workspace, since cargo reads them all to build one.
|
||||||
|
# ponytail: no dependency layer, so each build compiles sea-orm again; add the stub trick from
|
||||||
|
# the main Dockerfile if builds get slow enough to matter.
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY crates ./crates
|
||||||
|
# The logo and icon the public pages serve (#215), compiled into the binary.
|
||||||
|
COPY web/logo.svg web/logo-dark.svg web/favicon.png ./web/
|
||||||
|
RUN cargo build --release --locked -p ipx-directory
|
||||||
|
|
||||||
|
FROM debian:trixie-slim
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=build /src/target/release/ipx-directory /usr/local/bin/ipx-directory
|
||||||
|
ENV DIRECTORY_CONFIG=/config/directory.toml
|
||||||
|
# Unraid's nobody:users. It writes nothing to disk: the database is elsewhere.
|
||||||
|
USER 99:100
|
||||||
|
EXPOSE 8100
|
||||||
|
CMD ["ipx-directory"]
|
||||||
13
Dockerfile.web
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# ipx-web (#203): the frontend, built from web/ and served by Caddy, which passes the API to ipx.
|
||||||
|
# Built from the repo's root: docker buildx build -f Dockerfile.web .
|
||||||
|
FROM node:22-slim AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY web ./web
|
||||||
|
RUN node web/build.mjs /dist
|
||||||
|
|
||||||
|
FROM caddy:2
|
||||||
|
COPY web/Caddyfile /etc/caddy/Caddyfile
|
||||||
|
COPY --from=build /dist /srv
|
||||||
|
EXPOSE 8080
|
||||||
14
README.md
@@ -9,7 +9,7 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
|
|||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
- **The web UI.** It has a toolbar, and a feed list that opens with Directory, Popular and All
|
- **The web UI.** It has a toolbar, and a feed list that opens with the Directory, Currently Listening and All
|
||||||
Subscriptions. Items sit in a sortable table with a Files pane, and there is a player bar. It
|
Subscriptions. Items sit in a sortable table with a Files pane, and there is a player bar. It
|
||||||
comes in Dark, Light and Classic themes, and works on a phone.
|
comes in Dark, Light and Classic themes, and works on a phone.
|
||||||
- **Several people, one copy.** Each person has their own subscriptions and their own read, pinned
|
- **Several people, one copy.** Each person has their own subscriptions and their own read, pinned
|
||||||
@@ -17,8 +17,8 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
|
|||||||
sign in with a password or through a proxy (Cloudflare Zero Trust or Authentik), and admins
|
sign in with a password or through a proxy (Cloudflare Zero Trust or Authentik), and admins
|
||||||
manage accounts and settings.
|
manage accounts and settings.
|
||||||
- **Scanning.** Feeds are checked on a schedule, globally or per feed, and a feed's own TTL is
|
- **Scanning.** Feeds are checked on a schedule, globally or per feed, and a feed's own TTL is
|
||||||
honoured. Keyword, explicit-content and media-type filters decide what is downloaded, with a cap
|
honoured. Keyword, explicit-content and media-type filters decide what is downloaded, with a limit
|
||||||
on new downloads per scan.
|
on how many of a feed's newest episodes are downloaded.
|
||||||
- **Downloads.** Files come over HTTP or BitTorrent and are filed into a folder per feed.
|
- **Downloads.** Files come over HTTP or BitTorrent and are filed into a folder per feed.
|
||||||
Retention deletes the oldest files to stay under a disk quota or an age limit, and never touches
|
Retention deletes the oldest files to stay under a disk quota or an age limit, and never touches
|
||||||
an item someone has pinned.
|
an item someone has pinned.
|
||||||
@@ -31,11 +31,13 @@ With Docker:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t ipx .
|
docker build -t ipx .
|
||||||
|
docker build -f Dockerfile.web -t ipx-web .
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
`docker-compose.yml` is set up for the author's own server. Point its `image` and its three volumes
|
`docker-compose.yml` is a sample: ipx, with its database in SQLite and its three volumes
|
||||||
(`/config`, `/data` and `/downloads`) at yours first. The UI is on port 8099. BitTorrent uses 6881
|
(`/config`, `/data` and `/downloads`) in folders beside the file, and ipx-web, the page, served by
|
||||||
|
Caddy in front of it. Change them to suit. The UI is on port 8099. BitTorrent uses 6881
|
||||||
over TCP and UDP. Files are written as `PUID`/`PGID`, 99:100 by default.
|
over TCP and UDP. Files are written as `PUID`/`PGID`, 99:100 by default.
|
||||||
|
|
||||||
From source:
|
From source:
|
||||||
@@ -62,7 +64,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
|
|||||||
| [docs/users.md](docs/users.md) | Accounts, and what several people share |
|
| [docs/users.md](docs/users.md) | Accounts, and what several people share |
|
||||||
| [docs/sso.md](docs/sso.md) | Signing in through Cloudflare Zero Trust or Authentik |
|
| [docs/sso.md](docs/sso.md) | Signing in through Cloudflare Zero Trust or Authentik |
|
||||||
| [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API |
|
| [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API |
|
||||||
| [CHANGELOG.md](CHANGELOG.md) | What changed, by release |
|
| [crates/ipx/CHANGELOG.md](crates/ipx/CHANGELOG.md), [web/CHANGELOG.md](web/CHANGELOG.md), [crates/directory/CHANGELOG.md](crates/directory/CHANGELOG.md) | What changed, by release, for ipx, the page and the directory |
|
||||||
| [CLAUDE.md](CLAUDE.md) | Notes for working on the code, including how production is deployed |
|
| [CLAUDE.md](CLAUDE.md) | Notes for working on the code, including how production is deployed |
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|||||||
14
build.rs
@@ -1,14 +0,0 @@
|
|||||||
//! Builds web/index.html and web/login.html from their TypeScript (web/build.mjs) into OUT_DIR,
|
|
||||||
//! where src/web.rs include_str!s them. Needs node and `npm ci` run first.
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
println!("cargo:rerun-if-changed=web");
|
|
||||||
println!("cargo:rerun-if-changed=package-lock.json");
|
|
||||||
let out = std::env::var("OUT_DIR").unwrap();
|
|
||||||
let status = Command::new("node")
|
|
||||||
.args(["web/build.mjs", &out])
|
|
||||||
.status()
|
|
||||||
.expect("building the web pages needs node on PATH (and `npm ci` run once)");
|
|
||||||
assert!(status.success(), "web/build.mjs failed; run `node web/build.mjs` to see why");
|
|
||||||
}
|
|
||||||
23
crates/directory/CHANGELOG.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Changelog: ipx-directory
|
||||||
|
|
||||||
|
The directory of public podcasts that ipx servers share (docs/directory.md). ipx's changelog is
|
||||||
|
[crates/ipx/CHANGELOG.md](../ipx/CHANGELOG.md), the page's [web/CHANGELOG.md](../../web/CHANGELOG.md).
|
||||||
|
|
||||||
|
Its version is in `crates/directory/Cargo.toml`, and a release is tagged `directory-vX.Y.Z`.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-10-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Anyone can browse the directory in a web browser, read only: the most subscribed, categories, every feed A to Z, a search by name, and each feed's page with its latest items and its address to copy into any podcast app (#215).
|
||||||
|
- `GET /v1/instances` lists the servers holding a snapshot, with their feeds and subscribers, for an admin token (#206).
|
||||||
|
- `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
|
||||||
|
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/directory-v1.0.0
|
||||||
33
crates/directory/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
[package]
|
||||||
|
name = "ipx-directory"
|
||||||
|
version = "1.0.0"
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ipx-directory"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ammonia.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
ipx-feed.workspace = true
|
||||||
|
opentelemetry.workspace = true
|
||||||
|
opentelemetry-otlp.workspace = true
|
||||||
|
opentelemetry_sdk.workspace = true
|
||||||
|
percent-encoding.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
sea-orm.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-opentelemetry.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
url.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tower.workspace = true
|
||||||
172
crates/directory/src/config.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
//! The directory's config file: where it listens, where its database is, and the tokens it
|
||||||
|
//! accepts. Tokens are only ever changed here, never over the API, so whoever can edit this
|
||||||
|
//! file decides who gets in (#204).
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default = "default_bind")]
|
||||||
|
pub bind: String,
|
||||||
|
/// The environment variable holding the database URL, so the file holds no secret.
|
||||||
|
#[serde(default = "default_database_url_env")]
|
||||||
|
pub database_url_env: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tokens: Vec<TokenCfg>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_bind() -> String {
|
||||||
|
"0.0.0.0:8100".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_database_url_env() -> String {
|
||||||
|
"DIRECTORY_DATABASE_URL".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct TokenCfg {
|
||||||
|
/// Who holds it: what its snapshot is filed under and what the log says, never the token.
|
||||||
|
pub name: String,
|
||||||
|
/// No default: a token whose access is missing or misspelled is refused when the file is
|
||||||
|
/// read, rather than given some access nobody chose.
|
||||||
|
pub access: Access,
|
||||||
|
/// The token's SHA-256, in hex: what `ipx-directory token new` prints. The file holds no
|
||||||
|
/// token, so adding or revoking one is an edit here and nothing else.
|
||||||
|
pub token_sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a token may do, each level including the one before.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Access {
|
||||||
|
/// Browse.
|
||||||
|
Ro,
|
||||||
|
/// Browse and send snapshots.
|
||||||
|
Rw,
|
||||||
|
/// All that, and remove, block and refresh feeds and drop an instance's snapshot.
|
||||||
|
Rwa,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A token as the server checks it.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Token {
|
||||||
|
pub name: String,
|
||||||
|
pub access: Access,
|
||||||
|
/// SHA-256 of the token, lower-case hex.
|
||||||
|
pub sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load(path: &Path) -> Result<Self> {
|
||||||
|
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||||
|
Self::parse(&text).with_context(|| format!("in {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(text: &str) -> Result<Self> {
|
||||||
|
let cfg: Config = toml::from_str(text)?;
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for t in &cfg.tokens {
|
||||||
|
if t.name.trim().is_empty() {
|
||||||
|
bail!("a token has an empty name");
|
||||||
|
}
|
||||||
|
// Snapshots are filed by name: two instances under one name would overwrite each other.
|
||||||
|
if !seen.insert(t.name.as_str()) {
|
||||||
|
bail!("two tokens are named {:?}", t.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tokens as the server checks them. A hash that is not 64 hex digits, or the same
|
||||||
|
/// hash under two names, is an error: which name a request is would be ambiguous.
|
||||||
|
pub fn tokens(&self) -> Result<Vec<Token>> {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
self.tokens
|
||||||
|
.iter()
|
||||||
|
.map(|t| {
|
||||||
|
let sha256 = t.token_sha256.trim().to_ascii_lowercase();
|
||||||
|
if sha256.len() != 64 || !sha256.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||||
|
bail!("token {:?}: token_sha256 is not a SHA-256 in hex", t.name);
|
||||||
|
}
|
||||||
|
if !seen.insert(sha256.clone()) {
|
||||||
|
bail!("token {:?} is the same token as another", t.name);
|
||||||
|
}
|
||||||
|
Ok(Token { name: t.name.clone(), access: t.access, sha256 })
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A token's SHA-256 in hex, as the config file holds it.
|
||||||
|
pub fn hash(token: &str) -> String {
|
||||||
|
use sha2::Digest;
|
||||||
|
sha2::Sha256::digest(token.trim().as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Equal without saying, by how long it took, how much of the guess was right.
|
||||||
|
pub fn same_secret(a: &str, b: &str) -> bool {
|
||||||
|
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||||
|
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new token: `ipxd_` and 256 random bits, the prefix so one found in a log says what it is.
|
||||||
|
pub fn new_token() -> String {
|
||||||
|
use std::io::Read;
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
// Falling back to the clock would make a guessable token. Better to fail.
|
||||||
|
std::fs::File::open("/dev/urandom")
|
||||||
|
.and_then(|mut f| f.read_exact(&mut bytes))
|
||||||
|
.expect("no source of randomness for a token");
|
||||||
|
format!("ipxd_{}", bytes.iter().map(|b| format!("{b:02x}")).collect::<String>())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const GOOD: &str = r#"
|
||||||
|
[[tokens]]
|
||||||
|
name = "tower"
|
||||||
|
access = "rwa"
|
||||||
|
token_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
|
||||||
|
[[tokens]]
|
||||||
|
name = "friend"
|
||||||
|
access = "ro"
|
||||||
|
token_sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tokens_carry_their_access_and_take_their_value_from_the_environment() {
|
||||||
|
let cfg = Config::parse(GOOD).unwrap();
|
||||||
|
assert_eq!(cfg.bind, "0.0.0.0:8100");
|
||||||
|
let t = cfg.tokens().unwrap();
|
||||||
|
assert_eq!((t[0].name.as_str(), t[0].access, t[0].sha256.as_str()), ("tower", Access::Rwa, "a".repeat(64).as_str()));
|
||||||
|
assert_eq!(t[1].access, Access::Ro);
|
||||||
|
assert!(Access::Ro < Access::Rw && Access::Rw < Access::Rwa);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_or_misspelled_access_a_reused_name_or_a_bad_hash_is_refused() {
|
||||||
|
let h = "a".repeat(64);
|
||||||
|
assert!(Config::parse(&format!("[[tokens]]\nname = \"a\"\ntoken_sha256 = \"{h}\"")).is_err());
|
||||||
|
assert!(Config::parse(&format!("[[tokens]]\nname = \"a\"\naccess = \"admin\"\ntoken_sha256 = \"{h}\"")).is_err());
|
||||||
|
assert!(Config::parse(&GOOD.replace("\"friend\"", "\"tower\"")).is_err());
|
||||||
|
assert!(Config::parse(&GOOD.replace(&"b".repeat(64), "xyz")).unwrap().tokens().is_err());
|
||||||
|
assert!(Config::parse(&GOOD.replace(&"b".repeat(64), &h)).unwrap().tokens().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secrets_compare_whole() {
|
||||||
|
assert!(same_secret("abc", "abc"));
|
||||||
|
assert!(!same_secret("abc", "abd"));
|
||||||
|
assert!(!same_secret("abc", "abcd"));
|
||||||
|
let t = new_token();
|
||||||
|
assert!(t.starts_with("ipxd_") && t.len() == 69 && t != new_token());
|
||||||
|
assert_eq!(hash("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||||
|
}
|
||||||
|
}
|
||||||
561
crates/directory/src/lib.rs
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
//! ipx's directory service (#204): a catalogue of public podcasts and blogs that ipx servers
|
||||||
|
//! share. It holds metadata only, never a file: each server still fetches its own feeds. ipx
|
||||||
|
//! servers send which public feeds their people subscribe to, the directory fetches each
|
||||||
|
//! feed's title, artwork, description and latest items on a slow schedule, and any server
|
||||||
|
//! holding a token can browse it.
|
||||||
|
//!
|
||||||
|
//! The types here are the wire format, shared with ipx, which asks the directory on its
|
||||||
|
//! people's behalf.
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod otel;
|
||||||
|
mod site;
|
||||||
|
pub mod store;
|
||||||
|
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use axum::extract::{Path, Query, Request, State};
|
||||||
|
use axum::http::{StatusCode, header};
|
||||||
|
use axum::middleware::Next;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::{delete, get, post};
|
||||||
|
use axum::{Extension, Json, Router};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use config::{Access, Token};
|
||||||
|
use store::{Refreshed, Store};
|
||||||
|
|
||||||
|
/// A feed as the directory lists it.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Listing {
|
||||||
|
/// The first 64 bits of the SHA-256 of its normalised address (`store::feed_id`).
|
||||||
|
pub id: String,
|
||||||
|
pub url: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub image: Option<String>,
|
||||||
|
/// Apple's category and the subcategory within it, from the feed's own iTunes category.
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub subcategory: Option<String>,
|
||||||
|
/// Any audio or video in its latest items.
|
||||||
|
pub podcast: bool,
|
||||||
|
/// Summed over every instance's latest snapshot.
|
||||||
|
pub subscribers: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One feed's page: what it says it is, and its latest items.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FeedPage {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub listing: Listing,
|
||||||
|
/// The feed's own text, HTML as often as not, as it was sent: whoever shows it sanitizes it.
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Why its last check failed, in words, when it did.
|
||||||
|
pub failing: Option<String>,
|
||||||
|
pub items: Vec<Item>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One of a feed's latest items: what it is, never its files.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Item {
|
||||||
|
pub guid: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub link: Option<String>,
|
||||||
|
pub published: Option<i64>,
|
||||||
|
pub duration: Option<i64>,
|
||||||
|
/// As the feed sent it, unsanitized.
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One feed in a snapshot: its address, and how many people on the sending server subscribe.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SnapshotEntry {
|
||||||
|
pub url: String,
|
||||||
|
pub subscribers: i64,
|
||||||
|
/// The category the sending server's admin gave a feed that names none itself: used only
|
||||||
|
/// while the feed names none.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub category: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct SnapshotResult {
|
||||||
|
pub accepted: usize,
|
||||||
|
/// Addresses that look like they carry a subscriber's own access.
|
||||||
|
pub refused_private: usize,
|
||||||
|
/// Feeds an admin removed from the directory.
|
||||||
|
pub refused_blocked: usize,
|
||||||
|
/// Not an http(s) address.
|
||||||
|
pub refused_invalid: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Me {
|
||||||
|
pub name: String,
|
||||||
|
pub access: Access,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A token whose snapshot the directory holds: how many feeds it gave, and how many people.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Instance {
|
||||||
|
pub name: String,
|
||||||
|
pub feeds: i64,
|
||||||
|
pub subscribers: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Blocked {
|
||||||
|
pub id: String,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most a snapshot may list: far more than one server's subscriptions, few enough that one
|
||||||
|
/// request cannot hold the database for long.
|
||||||
|
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;
|
||||||
|
|
||||||
|
pub struct Directory {
|
||||||
|
pub store: Store,
|
||||||
|
tokens: RwLock<Vec<Token>>,
|
||||||
|
client: reqwest::Client,
|
||||||
|
/// Wakes the refresher, so a feed a snapshot brings in is fetched now, not in a minute.
|
||||||
|
wake: tokio::sync::Notify,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Shared = Arc<Directory>;
|
||||||
|
|
||||||
|
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()
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.build()?;
|
||||||
|
Ok(Arc::new(Self { store, tokens: RwLock::new(tokens), client, wake: tokio::sync::Notify::new() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes the tokens of a config file read again, and drops the snapshots of any that went.
|
||||||
|
pub async fn set_tokens(&self, tokens: Vec<Token>) -> anyhow::Result<u64> {
|
||||||
|
let names: Vec<String> = tokens.iter().map(|t| t.name.clone()).collect();
|
||||||
|
*self.tokens.write().unwrap() = tokens;
|
||||||
|
self.store.keep_instances(&names).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn caller(&self, presented: &str) -> Option<Me> {
|
||||||
|
let h = config::hash(presented);
|
||||||
|
// Every token compared, whether or not one matched, so timing says nothing either way.
|
||||||
|
self.tokens
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.fold(None, |found, t| if config::same_secret(&t.sha256, &h) { Some(Me { name: t.name.clone(), access: t.access }) } else { found })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches one feed and stores what it found.
|
||||||
|
pub async fn refresh(&self, due: &store::Due) -> anyhow::Result<()> {
|
||||||
|
let got = match ipx_feed::fetch(&self.client, &due.url, None, due.etag.as_deref(), due.last_modified.as_deref()).await {
|
||||||
|
Ok((ipx_feed::Fetched::NotModified, _)) => Refreshed::NotModified,
|
||||||
|
Ok((ipx_feed::Fetched::Body { bytes, etag, last_modified }, _)) => {
|
||||||
|
if ipx_feed::is_opml(&bytes) {
|
||||||
|
// ipx sends the feeds inside an OPML, never the OPML.
|
||||||
|
Refreshed::Failed("an OPML list, not a feed".into())
|
||||||
|
} else {
|
||||||
|
match ipx_feed::parse(&bytes) {
|
||||||
|
Ok(parsed) => Refreshed::Feed { parsed, etag, last_modified },
|
||||||
|
Err(e) => Refreshed::Failed(format!("{e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Refreshed::Failed(format!("{e:#}")),
|
||||||
|
};
|
||||||
|
if let Refreshed::Failed(e) = &got {
|
||||||
|
tracing::info!(feed = %due.id, error = %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.
|
||||||
|
self.store.save(&due.id, got).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches whatever is due, a few at a time, forever.
|
||||||
|
pub async fn refresher(dir: Shared) {
|
||||||
|
loop {
|
||||||
|
match dir.store.due(REFRESH_EVERY, 25).await {
|
||||||
|
Ok(due) => {
|
||||||
|
if !due.is_empty() {
|
||||||
|
// A round is a trace, each feed's check a span in it (#213).
|
||||||
|
let round = tracing::info_span!("refresh", feeds = due.len());
|
||||||
|
tracing::Instrument::instrument(
|
||||||
|
async {
|
||||||
|
for d in &due {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// One line a round, for the dashboard's refresher panel (#210); failures have their own.
|
||||||
|
tracing::info!(checked = due.len(), "feeds checked");
|
||||||
|
},
|
||||||
|
round,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not read which feeds are due"),
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(60)) => {}
|
||||||
|
_ = dir.wake.notified() => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the config file again whenever it changes, checked each minute. A file that fails to
|
||||||
|
/// load leaves the tokens as they were, so a typo locks nobody out.
|
||||||
|
pub async fn reloader(dir: Shared, path: std::path::PathBuf) {
|
||||||
|
let mtime = |p: &std::path::Path| std::fs::metadata(p).and_then(|m| m.modified()).ok();
|
||||||
|
let mut last = mtime(&path);
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||||
|
let now = mtime(&path);
|
||||||
|
if now == last {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
last = now;
|
||||||
|
match config::Config::load(&path).and_then(|c| c.tokens()) {
|
||||||
|
Ok(tokens) => {
|
||||||
|
let n = tokens.len();
|
||||||
|
match dir.set_tokens(tokens).await {
|
||||||
|
Ok(dropped) => tracing::info!(tokens = n, dropped, "config read again"),
|
||||||
|
Err(e) => tracing::warn!(error = %format!("{e:#}"), "config read again, but old snapshots could not be dropped"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(error = %format!("{e:#}"), "config changed but does not load; keeping the tokens as they were"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(dir: Shared) -> Router {
|
||||||
|
let v1 = Router::new()
|
||||||
|
.route("/v1/me", get(me))
|
||||||
|
.route("/v1/feeds", get(list))
|
||||||
|
.route("/v1/popular", get(popular))
|
||||||
|
.route("/v1/feeds/{id}", get(feed).delete(remove))
|
||||||
|
.route("/v1/feeds/{id}/refresh", post(refresh))
|
||||||
|
.route("/v1/snapshot", post(snapshot))
|
||||||
|
.route("/v1/blocked", get(blocked))
|
||||||
|
.route("/v1/blocked/{id}", delete(unblock))
|
||||||
|
.route("/v1/instances", get(instances))
|
||||||
|
.route("/v1/instances/{name}", delete(drop_instance))
|
||||||
|
.layer(axum::middleware::from_fn_with_state(dir.clone(), authenticate));
|
||||||
|
Router::new()
|
||||||
|
.route("/healthz", get(healthz))
|
||||||
|
.merge(v1)
|
||||||
|
// Anyone may browse, no token, read only (#215).
|
||||||
|
.merge(site::router())
|
||||||
|
.layer(axum::middleware::from_fn(access_log))
|
||||||
|
.with_state(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ApiError(StatusCode, String);
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
(self.0, Json(serde_json::json!({ "error": self.1 }))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<anyhow::Error> for ApiError {
|
||||||
|
fn from(e: anyhow::Error) -> Self {
|
||||||
|
tracing::error!(error = %format!("{e:#}"), "request failed");
|
||||||
|
ApiError(StatusCode::INTERNAL_SERVER_ERROR, "something went wrong; the directory's log says what".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who made the request, for the access log, set by `authenticate`.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Who(String);
|
||||||
|
|
||||||
|
/// One line a request, with the token's name and never the token: what the Grafana panels read.
|
||||||
|
async fn access_log(req: Request, next: Next) -> Response {
|
||||||
|
let (method, path) = (req.method().clone(), req.uri().path().to_owned());
|
||||||
|
let start = Instant::now();
|
||||||
|
// A span for each request but the healthcheck's, a child of the caller's when it sent a
|
||||||
|
// traceparent, as ipx does on its calls here (#213).
|
||||||
|
let span = if path == "/healthz" {
|
||||||
|
tracing::Span::none()
|
||||||
|
} else {
|
||||||
|
tracing::info_span!(
|
||||||
|
"http",
|
||||||
|
otel.name = %format!("{method} {path}"),
|
||||||
|
http.request.method = %method,
|
||||||
|
url.path = %path,
|
||||||
|
http.response.status_code = tracing::field::Empty,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if let Some(parent) = otel::remote_parent(req.headers(), None) {
|
||||||
|
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||||
|
let _ = span.set_parent(parent);
|
||||||
|
}
|
||||||
|
let resp = tracing::Instrument::instrument(next.run(req), span.clone()).await;
|
||||||
|
span.record("http.response.status_code", resp.status().as_u16());
|
||||||
|
let _in = span.enter();
|
||||||
|
let who = resp.extensions().get::<Who>().map_or("", |w| w.0.as_str()).to_owned();
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
if path != "/healthz" {
|
||||||
|
tracing::info!(
|
||||||
|
target: "ipx_directory::http",
|
||||||
|
method = %method, path = %path, status, duration_ms = start.elapsed().as_millis() as u64, token = %who,
|
||||||
|
"{method} {path} -> {status}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authenticate(State(dir): State<Shared>, mut req: Request, next: Next) -> Response {
|
||||||
|
let presented = req
|
||||||
|
.headers()
|
||||||
|
.get(header::AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|t| !t.is_empty());
|
||||||
|
let Some(me) = presented.and_then(|t| dir.caller(t)) else {
|
||||||
|
return ApiError(StatusCode::UNAUTHORIZED, "a valid token is needed: Authorization: Bearer <token>".into()).into_response();
|
||||||
|
};
|
||||||
|
let who = Who(me.name.clone());
|
||||||
|
req.extensions_mut().insert(me);
|
||||||
|
let mut resp = next.run(req).await;
|
||||||
|
resp.extensions_mut().insert(who);
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
|
fn need(me: &Me, access: Access) -> Result<(), ApiError> {
|
||||||
|
if me.access >= access {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let what = match access {
|
||||||
|
Access::Ro => "browse",
|
||||||
|
Access::Rw => "send snapshots",
|
||||||
|
Access::Rwa => "administer the directory",
|
||||||
|
};
|
||||||
|
Err(ApiError(StatusCode::FORBIDDEN, format!("this token may not {what}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn healthz(State(dir): State<Shared>) -> Result<&'static str, ApiError> {
|
||||||
|
dir.store.ping().await?;
|
||||||
|
Ok("ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn me(Extension(me): Extension<Me>) -> Json<Me> {
|
||||||
|
Json(me)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(State(dir): State<Shared>) -> Result<Json<Vec<Listing>>, ApiError> {
|
||||||
|
Ok(Json(dir.store.list().await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Limit {
|
||||||
|
limit: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn popular(State(dir): State<Shared>, Query(q): Query<Limit>) -> Result<Json<Vec<Listing>>, ApiError> {
|
||||||
|
Ok(Json(dir.store.popular(q.limit.unwrap_or(10).clamp(1, 100)).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Result<Json<FeedPage>, ApiError> {
|
||||||
|
dir.store.feed(&id).await?.map(Json).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not in the directory")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn snapshot(
|
||||||
|
State(dir): State<Shared>,
|
||||||
|
Extension(me): Extension<Me>,
|
||||||
|
Json(entries): Json<Vec<SnapshotEntry>>,
|
||||||
|
) -> Result<Json<SnapshotResult>, ApiError> {
|
||||||
|
need(&me, Access::Rw)?;
|
||||||
|
if entries.len() > MAX_SNAPSHOT {
|
||||||
|
return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("a snapshot lists at most {MAX_SNAPSHOT} feeds")));
|
||||||
|
}
|
||||||
|
let r = dir.store.snapshot(&me.name, &entries).await?;
|
||||||
|
dir.wake.notify_one();
|
||||||
|
tracing::info!(
|
||||||
|
instance = %me.name, accepted = r.accepted, refused_private = r.refused_private,
|
||||||
|
refused_blocked = r.refused_blocked, refused_invalid = r.refused_invalid, "snapshot"
|
||||||
|
);
|
||||||
|
Ok(Json(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
|
||||||
|
need(&me, Access::Rwa)?;
|
||||||
|
dir.store.block(&id).await?;
|
||||||
|
tracing::info!(feed = %id, by = %me.name, "feed removed and blocked");
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn blocked(State(dir): State<Shared>, Extension(me): Extension<Me>) -> Result<Json<Vec<Blocked>>, ApiError> {
|
||||||
|
need(&me, Access::Rwa)?;
|
||||||
|
Ok(Json(dir.store.blocked().await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unblock(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
|
||||||
|
need(&me, Access::Rwa)?;
|
||||||
|
if !dir.store.unblock(&id).await? {
|
||||||
|
return Err(ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not blocked")));
|
||||||
|
}
|
||||||
|
tracing::info!(feed = %id, by = %me.name, "feed unblocked");
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn instances(State(dir): State<Shared>, Extension(me): Extension<Me>) -> Result<Json<Vec<Instance>>, ApiError> {
|
||||||
|
need(&me, Access::Rwa)?;
|
||||||
|
Ok(Json(dir.store.instances().await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn drop_instance(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(name): Path<String>) -> Result<StatusCode, ApiError> {
|
||||||
|
need(&me, Access::Rwa)?;
|
||||||
|
let n = dir.store.drop_instance(&name).await?;
|
||||||
|
tracing::info!(instance = %name, feeds = n, by = %me.name, "snapshot dropped");
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(id): Path<String>) -> Result<Json<FeedPage>, ApiError> {
|
||||||
|
need(&me, Access::Rwa)?;
|
||||||
|
let due = dir.store.due_one(&id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not in the directory")))?;
|
||||||
|
dir.refresh(&due).await?;
|
||||||
|
feed(State(dir), Path(id)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::body::Body;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
async fn app() -> (Router, Shared) {
|
||||||
|
let tokens = ["ro", "rw", "rwa"]
|
||||||
|
.iter()
|
||||||
|
.map(|n| Token {
|
||||||
|
name: format!("{n}-token"),
|
||||||
|
access: serde_json::from_value(serde_json::json!(n)).unwrap(),
|
||||||
|
sha256: config::hash(&format!("secret-{n}")),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let dir = Directory::new(Store::scratch().await, tokens).unwrap();
|
||||||
|
(router(dir.clone()), dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call(app: &Router, method: &str, path: &str, token: Option<&str>, body: Option<serde_json::Value>) -> (u16, serde_json::Value) {
|
||||||
|
let mut req = axum::http::Request::builder().method(method).uri(path);
|
||||||
|
if let Some(t) = token {
|
||||||
|
req = req.header("authorization", format!("Bearer secret-{t}"));
|
||||||
|
}
|
||||||
|
let req = match body {
|
||||||
|
Some(b) => req.header("content-type", "application/json").body(Body::from(b.to_string())),
|
||||||
|
None => req.body(Body::empty()),
|
||||||
|
}
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||||
|
(status, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null))
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
let csp = resp.headers().get("content-security-policy").map(|v| v.to_str().unwrap().to_owned());
|
||||||
|
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 22).await.unwrap();
|
||||||
|
(status, String::from_utf8(bytes.to_vec()).unwrap(), csp)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn anyone_browses_the_pages_and_the_api_still_needs_a_token() {
|
||||||
|
let (app, dir) = app().await;
|
||||||
|
let snap = serde_json::json!([{ "url": "https://x.test/a.xml", "subscribers": 3 }]);
|
||||||
|
call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap)).await;
|
||||||
|
store::tests::fetch_all(&dir.store).await;
|
||||||
|
let id = dir.store.list().await.unwrap()[0].id.clone();
|
||||||
|
|
||||||
|
let (s, home, csp) = get_page(&app, "/").await;
|
||||||
|
assert_eq!(s, 200);
|
||||||
|
assert!(csp.unwrap().contains("script-src 'self'"));
|
||||||
|
assert!(home.contains("Most subscribed") && home.contains(&format!("/feed/{id}")), "{home}");
|
||||||
|
assert!(get_page(&app, "/all").await.1.contains("3 people subscribe"));
|
||||||
|
let (s, page, _) = get_page(&app, &format!("/feed/{id}")).await;
|
||||||
|
assert_eq!(s, 200);
|
||||||
|
// Its address, to subscribe to anywhere.
|
||||||
|
assert!(page.contains("value=\"https://x.test/a.xml\""), "{page}");
|
||||||
|
assert!(!page.contains("<script>"), "the publisher's HTML is text here");
|
||||||
|
assert_eq!(get_page(&app, "/feed/nope").await.0, 404);
|
||||||
|
assert_eq!(get_page(&app, "/category/Nothing").await.0, 404);
|
||||||
|
assert_eq!(get_page(&app, "/search?q=").await.0, 303);
|
||||||
|
assert_eq!(get_page(&app, "/site.js").await.0, 200);
|
||||||
|
// Reading the pages takes no token; the API still does.
|
||||||
|
assert_eq!(call(&app, "GET", "/v1/feeds", None, None).await.0, 401);
|
||||||
|
assert_eq!(call(&app, "DELETE", &format!("/v1/feeds/{id}"), None, None).await.0, 401);
|
||||||
|
assert_eq!(call(&app, "DELETE", &format!("/feed/{id}"), None, None).await.0, 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn each_access_level_does_what_it_may_and_no_more() {
|
||||||
|
let (app, dir) = app().await;
|
||||||
|
let snap = serde_json::json!([{ "url": "https://x.test/a.xml", "subscribers": 2, "category": "Comedy" }]);
|
||||||
|
|
||||||
|
assert_eq!(call(&app, "GET", "/v1/feeds", None, None).await.0, 401);
|
||||||
|
assert_eq!(call(&app, "GET", "/v1/feeds", Some("wrong"), None).await.0, 401);
|
||||||
|
assert_eq!(call(&app, "GET", "/healthz", None, None).await.0, 200);
|
||||||
|
|
||||||
|
let (s, me) = call(&app, "GET", "/v1/me", Some("ro"), None).await;
|
||||||
|
assert_eq!((s, me["name"].as_str(), me["access"].as_str()), (200, Some("ro-token"), Some("ro")));
|
||||||
|
assert_eq!(call(&app, "POST", "/v1/snapshot", Some("ro"), Some(snap.clone())).await.0, 403);
|
||||||
|
|
||||||
|
let (s, r) = call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap.clone())).await;
|
||||||
|
assert_eq!((s, r["accepted"].as_u64()), (200, Some(1)));
|
||||||
|
store::tests::fetch_all(&dir.store).await;
|
||||||
|
let (_, list) = call(&app, "GET", "/v1/feeds", Some("ro"), None).await;
|
||||||
|
let id = list[0]["id"].as_str().unwrap().to_owned();
|
||||||
|
assert_eq!(list[0]["subscribers"], 2);
|
||||||
|
assert_ne!(list[0]["category"], "Comedy", "the feed's own category wins over the sender's");
|
||||||
|
assert_eq!(call(&app, "GET", &format!("/v1/feeds/{id}"), Some("ro"), None).await.0, 200);
|
||||||
|
assert_eq!(call(&app, "GET", "/v1/feeds/nope", Some("ro"), None).await.0, 404);
|
||||||
|
assert_eq!(call(&app, "GET", "/v1/popular?limit=5", Some("ro"), None).await.1[0]["id"], id.as_str());
|
||||||
|
|
||||||
|
for (m, p) in [("DELETE", format!("/v1/feeds/{id}")), ("GET", "/v1/blocked".into()), ("DELETE", "/v1/instances/rw-token".into())] {
|
||||||
|
assert_eq!(call(&app, m, &p, Some("rw"), None).await.0, 403, "{m} {p}");
|
||||||
|
}
|
||||||
|
assert_eq!(call(&app, "DELETE", &format!("/v1/feeds/{id}"), Some("rwa"), None).await.0, 204);
|
||||||
|
assert_eq!(call(&app, "GET", "/v1/blocked", Some("rwa"), None).await.1[0]["id"], id.as_str());
|
||||||
|
assert_eq!(call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap.clone())).await.1["refused_blocked"], 1);
|
||||||
|
assert_eq!(call(&app, "DELETE", &format!("/v1/blocked/{id}"), Some("rwa"), None).await.0, 204);
|
||||||
|
assert_eq!(call(&app, "DELETE", &format!("/v1/blocked/{id}"), Some("rwa"), None).await.0, 404);
|
||||||
|
call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap)).await;
|
||||||
|
assert_eq!(call(&app, "DELETE", "/v1/instances/rw-token", Some("rwa"), None).await.0, 204);
|
||||||
|
assert_eq!(call(&app, "GET", "/v1/feeds", Some("ro"), None).await.1, serde_json::json!([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_revoked_token_loses_its_access_and_its_counts() {
|
||||||
|
let dir = Directory::new(Store::scratch().await, vec![]).unwrap();
|
||||||
|
let t = |n: &str| Token { name: n.into(), access: Access::Rw, sha256: config::hash(&format!("secret-{n}")) };
|
||||||
|
dir.set_tokens(vec![t("a"), t("b")]).await.unwrap();
|
||||||
|
let app = router(dir.clone());
|
||||||
|
let snap = serde_json::json!([{ "url": "https://x.test/a.xml", "subscribers": 1 }]);
|
||||||
|
call(&app, "POST", "/v1/snapshot", Some("a"), Some(snap.clone())).await;
|
||||||
|
call(&app, "POST", "/v1/snapshot", Some("b"), Some(snap)).await;
|
||||||
|
store::tests::fetch_all(&dir.store).await;
|
||||||
|
assert_eq!(call(&app, "GET", "/v1/feeds", Some("a"), None).await.1[0]["subscribers"], 2);
|
||||||
|
|
||||||
|
dir.set_tokens(vec![t("a")]).await.unwrap();
|
||||||
|
assert_eq!(call(&app, "GET", "/v1/feeds", Some("b"), None).await.0, 401);
|
||||||
|
assert_eq!(call(&app, "GET", "/v1/feeds", Some("a"), None).await.1[0]["subscribers"], 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
118
crates/directory/src/main.rs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
//! `ipx-directory`: serve the directory, make a token, or check that a running one answers.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use ipx_directory::{Directory, config, store::Store};
|
||||||
|
use tracing_subscriber::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(version, about = "ipx's directory of public podcasts")]
|
||||||
|
struct Cli {
|
||||||
|
/// The config file: where to listen, the database, and the tokens it accepts. Otherwise
|
||||||
|
/// DIRECTORY_CONFIG, or else /config/directory.toml.
|
||||||
|
#[arg(long)]
|
||||||
|
config: Option<std::path::PathBuf>,
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: Option<Cmd>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Cmd {
|
||||||
|
/// Serve the directory (the default).
|
||||||
|
Serve,
|
||||||
|
/// Make a token, and print it with the lines that let it in.
|
||||||
|
Token {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: TokenCmd,
|
||||||
|
},
|
||||||
|
/// Check that the directory on this machine answers and can read its database: the
|
||||||
|
/// container's healthcheck.
|
||||||
|
Health,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum TokenCmd {
|
||||||
|
New {
|
||||||
|
/// Who it is for, as the log and their snapshot will name them.
|
||||||
|
name: String,
|
||||||
|
#[arg(long, value_parser = ["ro", "rw", "rwa"])]
|
||||||
|
access: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let config_path = cli
|
||||||
|
.config
|
||||||
|
.or_else(|| std::env::var_os("DIRECTORY_CONFIG").map(Into::into))
|
||||||
|
.unwrap_or_else(|| "/config/directory.toml".into());
|
||||||
|
match cli.cmd.unwrap_or(Cmd::Serve) {
|
||||||
|
Cmd::Serve => serve(&config_path).await,
|
||||||
|
Cmd::Token { cmd: TokenCmd::New { name, access } } => {
|
||||||
|
let token = config::new_token();
|
||||||
|
println!("A token for {name}, to give them; it is not kept anywhere:\n\n {token}\n");
|
||||||
|
println!("To let it in, add this to {}:\n", config_path.display());
|
||||||
|
println!("[[tokens]]\nname = {name:?}\naccess = {access:?}\ntoken_sha256 = {:?}", config::hash(&token));
|
||||||
|
println!("\nThe directory reads the file again within a minute. Delete the entry to revoke it.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Cmd::Health => {
|
||||||
|
let cfg = config::Config::load(&config_path)?;
|
||||||
|
let port = cfg.bind.rsplit(':').next().unwrap_or("8100");
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.get(format!("http://127.0.0.1:{port}/healthz"))
|
||||||
|
.timeout(std::time::Duration::from_secs(4))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("the directory does not answer")?;
|
||||||
|
anyhow::ensure!(resp.status().is_success(), "the directory answers {}", resp.status());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve(path: &std::path::Path) -> Result<()> {
|
||||||
|
// One JSON object a line for Loki when DIRECTORY_LOG_FORMAT=json, as ipx does; text otherwise.
|
||||||
|
let json = std::env::var("DIRECTORY_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
|
||||||
|
let filter = || tracing_subscriber::EnvFilter::try_from_env("DIRECTORY_LOG").unwrap_or_else(|_| "ipx_directory=info".into());
|
||||||
|
// Traces to Tempo when OTEL_EXPORTER_OTLP_ENDPOINT is set, joining ipx's (#213).
|
||||||
|
let otel = ipx_directory::otel::provider("ipx-directory")?;
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with((!json).then(|| tracing_subscriber::fmt::layer().with_writer(std::io::stderr).with_filter(filter())))
|
||||||
|
.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"))
|
||||||
|
}))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cfg = config::Config::load(path)?;
|
||||||
|
let tokens = cfg.tokens()?;
|
||||||
|
let url = std::env::var(&cfg.database_url_env)
|
||||||
|
.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 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");
|
||||||
|
tokio::spawn(ipx_directory::refresher(dir.clone()));
|
||||||
|
tokio::spawn(ipx_directory::reloader(dir.clone(), path.to_owned()));
|
||||||
|
axum::serve(listener, ipx_directory::router(dir))
|
||||||
|
.with_graceful_shutdown(async {
|
||||||
|
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).expect("SIGTERM handler");
|
||||||
|
tokio::select! {
|
||||||
|
_ = term.recv() => {}
|
||||||
|
_ = tokio::signal::ctrl_c() => {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
// The batch exporter holds the last few seconds of spans; without this they are lost.
|
||||||
|
if let Some(p) = otel {
|
||||||
|
let _ = p.shutdown();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
83
crates/directory/src/otel.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
//! Traces, for ipx and the directory both (#213): the exporter, and W3C `traceparent` in from a
|
||||||
|
//! caller and out to a callee, so one trace follows a request from the browser through ipx-web
|
||||||
|
//! and ipx into the directory.
|
||||||
|
|
||||||
|
/// Traces over OTLP, to Tempo for one, when OTEL_EXPORTER_OTLP_ENDPOINT names a collector
|
||||||
|
/// (`http://host:4318`: the exporter speaks OTLP over HTTP and adds `/v1/traces`). The exporter
|
||||||
|
/// reads that and the other `OTEL_` variables itself.
|
||||||
|
pub fn provider(service: &'static str) -> anyhow::Result<Option<opentelemetry_sdk::trace::SdkTracerProvider>> {
|
||||||
|
if std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").unwrap_or_default().is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let exporter = opentelemetry_otlp::SpanExporter::builder().with_http().build()?;
|
||||||
|
let resource = opentelemetry_sdk::Resource::builder().with_service_name(service).build();
|
||||||
|
Ok(Some(
|
||||||
|
opentelemetry_sdk::trace::SdkTracerProvider::builder()
|
||||||
|
.with_batch_exporter(exporter)
|
||||||
|
.with_resource(resource)
|
||||||
|
.build(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The trace a request from the page belongs to, from its `traceparent` (#190), so the requests
|
||||||
|
/// one action sets off are one trace. None for a missing or malformed one. In the query where no
|
||||||
|
/// header can be set: a position saved by sendBeacon, and the playlist Safari's player fetches
|
||||||
|
/// itself (#192).
|
||||||
|
pub fn remote_parent(headers: &axum::http::HeaderMap, query: Option<&str>) -> Option<opentelemetry::Context> {
|
||||||
|
use opentelemetry::trace::{SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState};
|
||||||
|
let v = match headers.get("traceparent") {
|
||||||
|
Some(h) => h.to_str().ok()?,
|
||||||
|
None => query?.split('&').find_map(|kv| kv.strip_prefix("traceparent="))?,
|
||||||
|
};
|
||||||
|
let mut parts = v.split('-');
|
||||||
|
let (version, trace, span) = (parts.next()?, parts.next()?, parts.next()?);
|
||||||
|
if version != "00" || trace.len() != 32 || span.len() != 16 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let sc = SpanContext::new(
|
||||||
|
TraceId::from_hex(trace).ok()?,
|
||||||
|
SpanId::from_hex(span).ok()?,
|
||||||
|
TraceFlags::SAMPLED,
|
||||||
|
true,
|
||||||
|
TraceState::default(),
|
||||||
|
);
|
||||||
|
sc.is_valid().then(|| opentelemetry::Context::new().with_remote_span_context(sc))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current span as a `traceparent`, for a request to another service, so its spans join
|
||||||
|
/// this trace. None outside a traced span.
|
||||||
|
pub fn traceparent() -> Option<String> {
|
||||||
|
use opentelemetry::trace::TraceContextExt;
|
||||||
|
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||||
|
let cx = tracing::Span::current().context();
|
||||||
|
let sc = cx.span().span_context().clone();
|
||||||
|
sc.is_valid().then(|| format!("00-{}-{}-{:02x}", sc.trace_id(), sc.span_id(), sc.trace_flags().to_u8()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_requests_trace_is_the_pages_from_its_traceparent() {
|
||||||
|
use opentelemetry::trace::TraceContextExt;
|
||||||
|
let with = |v: &str| {
|
||||||
|
let mut h = axum::http::HeaderMap::new();
|
||||||
|
h.insert("traceparent", v.parse().unwrap());
|
||||||
|
remote_parent(&h, None)
|
||||||
|
};
|
||||||
|
let cx = with("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").expect("parsed");
|
||||||
|
assert_eq!(cx.span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||||
|
assert!(cx.span().span_context().is_remote());
|
||||||
|
// Anything else from the browser starts a trace of its own.
|
||||||
|
for bad in ["", "00-xyz-00f067aa0ba902b7-01", "01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
|
||||||
|
"00-00000000000000000000000000000000-00f067aa0ba902b7-01", "00-4bf92f3577b34da6a3ce929d0e0e4736"] {
|
||||||
|
assert!(with(bad).is_none(), "{bad}");
|
||||||
|
}
|
||||||
|
assert!(remote_parent(&axum::http::HeaderMap::new(), None).is_none());
|
||||||
|
// From the query, where a beacon or Safari's player cannot send a header (#192).
|
||||||
|
let q = remote_parent(&axum::http::HeaderMap::new(), Some("x=1&traceparent=00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"));
|
||||||
|
assert_eq!(q.unwrap().span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||||
|
assert!(remote_parent(&axum::http::HeaderMap::new(), Some("traceparent=nonsense")).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
67
crates/directory/src/site.css
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/* The directory's pages (#215), in iPX's palette: the logo's navy, sky blue and needle orange,
|
||||||
|
dark or light as the system is. */
|
||||||
|
:root{--bg:#eef5fb;--panel:#fff;--panel2:#e4eff9;--line:#c6d9ea;--fg:#10233a;--dim:#46596e;--faint:#56697e;--accent:#2f6aa0;--accent2:#c43e00;--ink:#fff}
|
||||||
|
@media (prefers-color-scheme:dark){:root{--bg:#0a1726;--panel:#0f1f31;--panel2:#15283d;--line:#264060;--fg:#f3f7fb;--dim:#a3b6ca;--faint:#8a9fb5;--accent:#8fc2ea;--accent2:#ff6a1a;--ink:#0a1726}}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html{-webkit-text-size-adjust:100%}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||||||
|
a{color:var(--accent);text-decoration:none}
|
||||||
|
a:hover{text-decoration:underline}
|
||||||
|
a:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||||
|
header{display:flex;align-items:center;gap:16px;padding:12px 20px;background:var(--panel);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:1}
|
||||||
|
.brand{display:flex;align-items:center;gap:10px;color:var(--fg);font-weight:700;font-size:17px;white-space:nowrap}
|
||||||
|
.brand img{border-radius:8px;display:block}
|
||||||
|
header form{margin-left:auto;flex:0 1 320px}
|
||||||
|
input{font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:8px 12px;width:100%}
|
||||||
|
main{max-width:1100px;margin:0 auto;padding:20px 20px 40px}
|
||||||
|
footer{max-width:1100px;margin:0 auto;padding:16px 20px 32px;color:var(--faint);font-size:13px;border-top:1px solid var(--line)}
|
||||||
|
h2{font-size:22px;margin:20px 0 12px}
|
||||||
|
h3{font-size:17px;margin:28px 0 8px}
|
||||||
|
.hint,.count{color:var(--dim)}
|
||||||
|
.back{margin:0;color:var(--faint);font-size:14px}
|
||||||
|
.more{font-weight:600}
|
||||||
|
.art{position:relative;display:block;aspect-ratio:1;border-radius:10px;overflow:hidden;background:var(--panel2);flex:none}
|
||||||
|
.art i{position:absolute;inset:0;display:grid;place-items:center;font-style:normal;font-weight:700;color:var(--accent);font-size:28px}
|
||||||
|
.art img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
||||||
|
.art.sm{width:48px;border-radius:8px}.art.sm i{font-size:16px}
|
||||||
|
.art.lg{width:180px;border-radius:14px}.art.lg i{font-size:48px}
|
||||||
|
.chart{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(320px,100%),1fr));gap:4px 24px}
|
||||||
|
/* A grid item is as wide as its content unless told otherwise, and a long title then runs off the page. */
|
||||||
|
.chart li,.tile{min-width:0}
|
||||||
|
.chart a{display:flex;align-items:center;gap:12px;padding:8px;border-radius:10px;color:var(--fg)}
|
||||||
|
.chart a:hover{background:var(--panel);text-decoration:none}
|
||||||
|
.no{width:22px;text-align:right;color:var(--faint);font-variant-numeric:tabular-nums;flex:none}
|
||||||
|
.txt{display:flex;flex-direction:column;min-width:0}
|
||||||
|
.txt b,.tile b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
small{color:var(--dim);font-size:13px}
|
||||||
|
.cats{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px}
|
||||||
|
.cat{display:flex;flex-direction:column;gap:2px;padding:14px 16px;border-radius:12px;background:var(--panel);border:1px solid var(--line);color:var(--fg)}
|
||||||
|
.cat:hover{border-color:var(--accent);text-decoration:none}
|
||||||
|
.tabs,.pills{display:flex;gap:6px;flex-wrap:wrap;margin:0 0 12px}
|
||||||
|
.tabs a,.pills a{padding:6px 14px;border-radius:999px;background:var(--panel);border:1px solid var(--line);color:var(--fg);font-size:14px}
|
||||||
|
.tabs a[aria-current],.pills a[aria-current]{background:var(--accent);border-color:var(--accent);color:var(--ink)}
|
||||||
|
.pills a[aria-current] small{color:inherit}
|
||||||
|
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:18px 14px}
|
||||||
|
.tile{display:flex;flex-direction:column;gap:4px;min-width:0;color:var(--fg)}
|
||||||
|
.tile:hover{text-decoration:none}.tile:hover b{text-decoration:underline}
|
||||||
|
.show{display:flex;gap:24px;align-items:flex-end;margin-top:8px}
|
||||||
|
.meta{display:flex;flex-direction:column;gap:6px;min-width:0;flex:1}
|
||||||
|
.meta h2{margin:0;font-size:28px;line-height:1.15;overflow-wrap:anywhere}
|
||||||
|
.sub{margin:0;color:var(--dim)}
|
||||||
|
.addr{display:flex;flex-direction:column;gap:4px;margin-top:8px;max-width:560px}
|
||||||
|
.addr label{font-size:13px;color:var(--faint)}
|
||||||
|
.addr .row{display:flex;gap:8px}
|
||||||
|
.addr input{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}
|
||||||
|
button{font:inherit;font-weight:600;padding:8px 16px;border-radius:10px;border:0;background:var(--accent);color:var(--ink);cursor:pointer}
|
||||||
|
.about{color:var(--dim);max-width:72ch;margin:20px 0 0}
|
||||||
|
.failing{color:var(--accent2)}
|
||||||
|
article{padding:12px 0;border-bottom:1px solid var(--line);max-width:80ch}
|
||||||
|
article p{margin:4px 0;color:var(--dim)}
|
||||||
|
@media (max-width:600px){
|
||||||
|
header{padding:10px 16px;gap:10px}
|
||||||
|
.brand span,.brand{font-size:15px}
|
||||||
|
main{padding:16px}
|
||||||
|
.show{flex-direction:column;align-items:flex-start;gap:14px}
|
||||||
|
.art.lg{width:140px}
|
||||||
|
.tiles{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:14px 10px}
|
||||||
|
}
|
||||||
10
crates/directory/src/site.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// The one thing the directory's pages script (#215): Copy puts a feed's address on the clipboard.
|
||||||
|
// Without it, the address is in a field of its own to select by hand.
|
||||||
|
document.addEventListener('click', ev => {
|
||||||
|
const b = ev.target.closest('[data-copy]');
|
||||||
|
if (!b || !navigator.clipboard) return;
|
||||||
|
navigator.clipboard.writeText(b.dataset.copy).then(() => {
|
||||||
|
b.textContent = 'Copied';
|
||||||
|
setTimeout(() => { b.textContent = 'Copy'; }, 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
348
crates/directory/src/site.rs
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
//! The directory in a web browser (#215): anyone may browse it, no token, read only. The most
|
||||||
|
//! subscribed and the categories, every feed A to Z, a search by name, and a page per feed with
|
||||||
|
//! its address to subscribe to in any podcast app. Rendered here from the store, as plain HTML:
|
||||||
|
//! no build step, and nothing the API does not already hold.
|
||||||
|
//!
|
||||||
|
//! A feed's description and items are the publisher's HTML, unsanitized in the store; they are
|
||||||
|
//! shown as text, tags stripped, and an item's link only when it is http(s).
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::http::{HeaderValue, StatusCode, header};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
|
|
||||||
|
use crate::{Listing, Shared};
|
||||||
|
|
||||||
|
pub fn router() -> Router<Shared> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(home))
|
||||||
|
.route("/all", get(all))
|
||||||
|
.route("/search", get(search))
|
||||||
|
.route("/category/{name}", get(category))
|
||||||
|
.route("/feed/{id}", get(feed))
|
||||||
|
.route("/site.css", get(|| asset("text/css; charset=utf-8", include_bytes!("site.css"))))
|
||||||
|
.route("/site.js", get(|| asset("text/javascript; charset=utf-8", include_bytes!("site.js"))))
|
||||||
|
.route("/logo.svg", get(|| asset("image/svg+xml", include_bytes!("../../../web/logo.svg"))))
|
||||||
|
.route("/logo-dark.svg", get(|| asset("image/svg+xml", include_bytes!("../../../web/logo-dark.svg"))))
|
||||||
|
.route("/favicon.png", get(|| asset("image/png", include_bytes!("../../../web/favicon.png"))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Its own script and stylesheet only; covers come from their publishers.
|
||||||
|
const CSP: &str = "default-src 'none'; img-src https: http: data:; style-src 'self'; script-src 'self'; \
|
||||||
|
base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
|
||||||
|
|
||||||
|
async fn asset(kind: &'static str, body: &'static [u8]) -> Response {
|
||||||
|
([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "public, max-age=86400")], body).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, Default)]
|
||||||
|
struct Filter {
|
||||||
|
/// `podcasts` or `blogs`; anything else is both.
|
||||||
|
kind: Option<String>,
|
||||||
|
sub: Option<String>,
|
||||||
|
q: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Filter {
|
||||||
|
fn keep(&self, l: &Listing) -> bool {
|
||||||
|
match self.kind.as_deref() {
|
||||||
|
Some("podcasts") => l.podcast,
|
||||||
|
Some("blogs") => !l.podcast,
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn home(State(dir): State<Shared>) -> Response {
|
||||||
|
let all = match dir.store.list().await {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => return failed(e),
|
||||||
|
};
|
||||||
|
let mut top = all.clone();
|
||||||
|
top.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| name(a).to_lowercase().cmp(&name(b).to_lowercase())));
|
||||||
|
top.truncate(10);
|
||||||
|
let mut cats: BTreeMap<&str, Vec<&Listing>> = BTreeMap::new();
|
||||||
|
for l in &all {
|
||||||
|
cats.entry(l.category.as_deref().unwrap_or("Other")).or_default().push(l);
|
||||||
|
}
|
||||||
|
let mut b = String::new();
|
||||||
|
if all.is_empty() {
|
||||||
|
b.push_str("<p class=hint>Nothing is listed yet. A feed appears once a server has shared it and the directory has read it.</p>");
|
||||||
|
} else {
|
||||||
|
b.push_str("<h2>Most subscribed</h2><ol class=chart>");
|
||||||
|
for (i, l) in top.iter().enumerate() {
|
||||||
|
let _ = write!(b, "<li><a href=\"/feed/{}\"><span class=no>{}</span>{}<span class=txt><b>{}</b><small>{}</small></span></a></li>",
|
||||||
|
esc(&l.id), i + 1, art(l, "art sm"), esc(name(l)), subscribers(l.subscribers));
|
||||||
|
}
|
||||||
|
b.push_str("</ol><h2>Categories</h2><div class=cats>");
|
||||||
|
for (c, ls) in &cats {
|
||||||
|
let _ = write!(b, "<a class=cat href=\"/category/{}\"><b>{}</b><small>{}</small></a>", seg(c), esc(c), ls.len());
|
||||||
|
}
|
||||||
|
let _ = write!(b, "</div><p><a class=more href=\"/all\">All {} feeds, A to Z</a></p>", all.len());
|
||||||
|
}
|
||||||
|
page("iPX Directory", None, &b)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn all(State(dir): State<Shared>, Query(f): Query<Filter>) -> Response {
|
||||||
|
listed(&dir, "All feeds", "/all", &f, |_| true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(State(dir): State<Shared>, Query(f): Query<Filter>) -> Response {
|
||||||
|
let q = f.q.clone().unwrap_or_default().trim().to_lowercase();
|
||||||
|
if q.is_empty() {
|
||||||
|
return redirect("/all");
|
||||||
|
}
|
||||||
|
let title = format!("Results for \u{201c}{}\u{201d}", f.q.as_deref().unwrap_or("").trim());
|
||||||
|
listed(&dir, &title, "/search", &f, move |l| name(l).to_lowercase().contains(&q)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn category(State(dir): State<Shared>, Path(c): Path<String>, Query(f): Query<Filter>) -> Response {
|
||||||
|
let here = format!("/category/{}", seg(&c));
|
||||||
|
let cat = c.clone();
|
||||||
|
listed(&dir, &c, &here, &f, move |l| l.category.as_deref().unwrap_or("Other") == cat).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page of tiles: those `pick` takes, of the kind and subcategory asked for, A to Z.
|
||||||
|
async fn listed(dir: &Shared, title: &str, here: &str, f: &Filter, pick: impl Fn(&Listing) -> bool) -> Response {
|
||||||
|
let mut ls: Vec<Listing> = match dir.store.list().await {
|
||||||
|
Ok(a) => a.into_iter().filter(|l| pick(l)).collect(),
|
||||||
|
Err(e) => return failed(e),
|
||||||
|
};
|
||||||
|
if ls.is_empty() && here.starts_with("/category/") {
|
||||||
|
return not_found("That category has nothing in it.");
|
||||||
|
}
|
||||||
|
let subs: BTreeMap<String, usize> = ls.iter().filter_map(|l| l.subcategory.clone()).fold(BTreeMap::new(), |mut m, s| {
|
||||||
|
*m.entry(s).or_default() += 1;
|
||||||
|
m
|
||||||
|
});
|
||||||
|
ls.retain(|l| f.keep(l) && f.sub.as_ref().is_none_or(|s| l.subcategory.as_ref() == Some(s)));
|
||||||
|
ls.sort_by_key(|l| name(l).to_lowercase());
|
||||||
|
|
||||||
|
// The query a link keeps, with one of its keys changed.
|
||||||
|
let link = |kind: Option<&str>, sub: Option<&str>| {
|
||||||
|
let mut q = url::form_urlencoded::Serializer::new(String::new());
|
||||||
|
if let Some(v) = f.q.as_deref() {
|
||||||
|
q.append_pair("q", v);
|
||||||
|
}
|
||||||
|
if let Some(v) = kind {
|
||||||
|
q.append_pair("kind", v);
|
||||||
|
}
|
||||||
|
if let Some(v) = sub {
|
||||||
|
q.append_pair("sub", v);
|
||||||
|
}
|
||||||
|
let q = q.finish();
|
||||||
|
if q.is_empty() { here.to_owned() } else { format!("{here}?{q}") }
|
||||||
|
};
|
||||||
|
let mut b = String::new();
|
||||||
|
let _ = write!(b, "<p class=back><a href=\"/\">Directory</a></p><h2>{}</h2><nav class=tabs aria-label=Kind>", esc(title));
|
||||||
|
for (k, label) in [(None, "All"), (Some("podcasts"), "Podcasts"), (Some("blogs"), "Blogs")] {
|
||||||
|
let on = f.kind.as_deref() == k;
|
||||||
|
let _ = write!(b, "<a href=\"{}\"{}>{label}</a>", esc(&link(k, f.sub.as_deref())), if on { " aria-current=page" } else { "" });
|
||||||
|
}
|
||||||
|
b.push_str("</nav>");
|
||||||
|
if !subs.is_empty() {
|
||||||
|
b.push_str("<nav class=pills aria-label=Subcategory>");
|
||||||
|
let _ = write!(b, "<a href=\"{}\"{}>All</a>", esc(&link(f.kind.as_deref(), None)), if f.sub.is_none() { " aria-current=page" } else { "" });
|
||||||
|
for (s, n) in &subs {
|
||||||
|
let on = f.sub.as_deref() == Some(s.as_str());
|
||||||
|
let _ = write!(b, "<a href=\"{}\"{}>{} <small>{n}</small></a>", esc(&link(f.kind.as_deref(), Some(s))), if on { " aria-current=page" } else { "" }, esc(s));
|
||||||
|
}
|
||||||
|
b.push_str("</nav>");
|
||||||
|
}
|
||||||
|
if ls.is_empty() {
|
||||||
|
b.push_str("<p class=hint>Nothing here.</p>");
|
||||||
|
} else {
|
||||||
|
let _ = write!(b, "<p class=count>{}</p><div class=tiles>", plural(ls.len(), "feed"));
|
||||||
|
for l in &ls {
|
||||||
|
let _ = write!(b, "<a class=tile href=\"/feed/{}\">{}<b>{}</b><small>{}</small></a>", esc(&l.id), art(l, "art"), esc(name(l)), subscribers(l.subscribers));
|
||||||
|
}
|
||||||
|
b.push_str("</div>");
|
||||||
|
}
|
||||||
|
page(title, None, &b)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Response {
|
||||||
|
let p = match dir.store.feed(&id).await {
|
||||||
|
Ok(Some(p)) => p,
|
||||||
|
Ok(None) => return not_found("That feed is not in the directory."),
|
||||||
|
Err(e) => return failed(e),
|
||||||
|
};
|
||||||
|
let l = &p.listing;
|
||||||
|
let mut b = String::new();
|
||||||
|
b.push_str("<p class=back><a href=\"/\">Directory</a>");
|
||||||
|
if let Some(c) = &l.category {
|
||||||
|
let _ = write!(b, " <span aria-hidden=true>/</span> <a href=\"/category/{}\">{}</a>", seg(c), esc(c));
|
||||||
|
}
|
||||||
|
let _ = write!(b, "</p><div class=show>{}<div class=meta><h2>{}</h2><p class=sub>{}{}</p>", art(l, "art lg"), esc(name(l)),
|
||||||
|
subscribers(l.subscribers), l.subcategory.as_deref().map(|s| format!(" · {}", esc(s))).unwrap_or_default());
|
||||||
|
let _ = write!(b, "<div class=addr><label for=addr>Feed address</label><span class=row><input id=addr readonly value=\"{}\" spellcheck=false>\
|
||||||
|
<button type=button data-copy=\"{}\">Copy</button></span><small>Paste it into any podcast app or feed reader to subscribe.</small></div></div></div>",
|
||||||
|
esc(&l.url), esc(&l.url));
|
||||||
|
if let Some(d) = p.description.as_deref().map(text).filter(|d| !d.is_empty() && d != name(l)) {
|
||||||
|
let _ = write!(b, "<p class=about>{}</p>", esc(&d));
|
||||||
|
}
|
||||||
|
if let Some(why) = &p.failing {
|
||||||
|
let _ = write!(b, "<p class=failing>{}</p>", esc(why));
|
||||||
|
}
|
||||||
|
let _ = write!(b, "<h3>{}</h3>", if l.podcast { "Latest episodes" } else { "Latest posts" });
|
||||||
|
if p.items.is_empty() {
|
||||||
|
b.push_str("<p class=hint>Nothing read from it yet. The directory checks each feed every six hours.</p>");
|
||||||
|
}
|
||||||
|
for e in &p.items {
|
||||||
|
let title = e.title.as_deref().map(text).filter(|t| !t.is_empty()).unwrap_or_else(|| "Untitled".into());
|
||||||
|
let head = match e.link.as_deref().filter(|u| u.starts_with("https://") || u.starts_with("http://")) {
|
||||||
|
Some(u) => format!("<a href=\"{}\" rel=\"noopener nofollow\">{}</a>", esc(u), esc(&title)),
|
||||||
|
None => esc(&title),
|
||||||
|
};
|
||||||
|
let blurb: String = e.description.as_deref().map(text).unwrap_or_default().chars().take(400).collect();
|
||||||
|
let when = [e.published.map(date), e.duration.filter(|d| *d > 0).map(clock)].into_iter().flatten().collect::<Vec<_>>().join(" · ");
|
||||||
|
let _ = write!(b, "<article><b>{head}</b>{}{}</article>",
|
||||||
|
if blurb.is_empty() { String::new() } else { format!("<p>{}</p>", esc(&blurb)) },
|
||||||
|
if when.is_empty() { String::new() } else { format!("<small>{when}</small>") });
|
||||||
|
}
|
||||||
|
page(name(l), Some(&text(p.description.as_deref().unwrap_or(""))), &b)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page(title: &str, description: Option<&str>, body: &str) -> Response {
|
||||||
|
let full = if title == "iPX Directory" { title.to_owned() } else { format!("{title} | iPX Directory") };
|
||||||
|
let desc = description.filter(|d| !d.is_empty()).map(|d| d.chars().take(200).collect::<String>())
|
||||||
|
.unwrap_or_else(|| "Public podcasts and blogs that iPX servers share.".into());
|
||||||
|
let html = format!(
|
||||||
|
"<!doctype html><html lang=en><head><meta charset=utf-8><meta name=viewport content=\"width=device-width,initial-scale=1\">\
|
||||||
|
<title>{}</title><meta name=description content=\"{}\"><meta name=color-scheme content=\"light dark\">\
|
||||||
|
<link rel=icon href=/favicon.png><link rel=stylesheet href=/site.css><script src=/site.js defer></script></head>\
|
||||||
|
<body><header><a class=brand href=\"/\"><picture><source srcset=/logo-dark.svg media=\"(prefers-color-scheme: dark)\">\
|
||||||
|
<img src=/logo.svg alt=\"\" width=32 height=32></picture>iPX Directory</a>\
|
||||||
|
<form action=/search role=search><input type=search name=q placeholder=\"Find a feed\" aria-label=\"Find a feed by name\"></form></header>\
|
||||||
|
<main>{body}</main><footer>Public podcasts and blogs that iPX servers share, and how many people on them subscribe. \
|
||||||
|
Read only: to change what is here, an iPX server shares its public feeds.</footer></body></html>",
|
||||||
|
esc(&full),
|
||||||
|
esc(&desc),
|
||||||
|
);
|
||||||
|
let mut r = html.into_response();
|
||||||
|
let h = r.headers_mut();
|
||||||
|
h.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
|
||||||
|
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=300"));
|
||||||
|
h.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static(CSP));
|
||||||
|
h.insert("x-content-type-options", HeaderValue::from_static("nosniff"));
|
||||||
|
h.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
fn not_found(why: &str) -> Response {
|
||||||
|
let mut r = page("Not found", None, &format!("<p class=back><a href=\"/\">Directory</a></p><h2>Not found</h2><p class=hint>{}</p>", esc(why)));
|
||||||
|
*r.status_mut() = StatusCode::NOT_FOUND;
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failed(e: anyhow::Error) -> Response {
|
||||||
|
tracing::error!(error = %format!("{e:#}"), "a directory page failed");
|
||||||
|
let mut r = page("Unavailable", None, "<h2>Unavailable</h2><p class=hint>The directory could not read its list just now. Try again in a minute.</p>");
|
||||||
|
*r.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redirect(to: &'static str) -> Response {
|
||||||
|
(StatusCode::SEE_OTHER, [(header::LOCATION, to)]).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cover from its publisher, over the feed's initials, which show where it has none or it fails.
|
||||||
|
fn art(l: &Listing, class: &str) -> String {
|
||||||
|
// The first letter of each of the first two words that have one: `<antirez>` is A, not nothing.
|
||||||
|
let initials: String = name(l).split_whitespace().filter_map(|w| w.chars().find(|c| c.is_alphanumeric())).take(2).collect();
|
||||||
|
let img = l.image.as_deref().filter(|u| u.starts_with("https://") || u.starts_with("http://"))
|
||||||
|
.map(|u| format!("<img src=\"{}\" alt=\"\" loading=lazy referrerpolicy=no-referrer>", esc(u)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("<span class=\"{class}\" aria-hidden=true><i>{}</i>{img}</span>", esc(&initials.to_uppercase()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(l: &Listing) -> &str {
|
||||||
|
l.title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(&l.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribers(n: i64) -> String {
|
||||||
|
match n {
|
||||||
|
0 => "Listed, no subscribers yet".into(),
|
||||||
|
1 => "1 person subscribes".into(),
|
||||||
|
n => format!("{n} people subscribe"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plural(n: usize, word: &str) -> String {
|
||||||
|
format!("{n} {word}{}", if n == 1 { "" } else { "s" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `12 Oct 2026`, from a Unix time, with no date library: the civil-from-days algorithm.
|
||||||
|
fn date(t: i64) -> String {
|
||||||
|
let z = t.div_euclid(86_400) + 719_468;
|
||||||
|
let era = z.div_euclid(146_097);
|
||||||
|
let doe = z - era * 146_097;
|
||||||
|
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||||
|
let y = yoe + era * 400 + i64::from(m <= 2);
|
||||||
|
const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
|
format!("{d} {} {y}", MONTHS[(m - 1) as usize])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clock(secs: i64) -> String {
|
||||||
|
let (h, m) = (secs / 3600, secs % 3600 / 60);
|
||||||
|
if h > 0 { format!("{h} h {m} min") } else { format!("{} min", m.max(1)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The publisher's HTML as plain text: tags gone, entities read, whitespace collapsed. Escaped
|
||||||
|
/// again where it is shown.
|
||||||
|
fn text(html: &str) -> String {
|
||||||
|
// A space where each tag was, or two paragraphs run together: "drives.For over".
|
||||||
|
let stripped = ammonia::Builder::empty().clean(&html.replace('<', " <")).to_string();
|
||||||
|
let decoded = stripped.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace(" ", " ").replace("&", "&");
|
||||||
|
decoded.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn esc(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'&' => out.push_str("&"),
|
||||||
|
'<' => out.push_str("<"),
|
||||||
|
'>' => out.push_str(">"),
|
||||||
|
'"' => out.push_str("""),
|
||||||
|
'\'' => out.push_str("'"),
|
||||||
|
c => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A category as a path segment.
|
||||||
|
fn seg(s: &str) -> String {
|
||||||
|
percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_publishers_html_becomes_text_and_is_escaped_where_shown() {
|
||||||
|
assert_eq!(text("<p>Tom & Jerry <script>alert(1)</script><b>live</b></p>"), "Tom & Jerry live");
|
||||||
|
assert_eq!(text("<p>One.</p><p>Two.</p>"), "One. Two.");
|
||||||
|
assert_eq!(esc(&text("a <b> c")), "a <b> c");
|
||||||
|
assert_eq!(esc("\"><img onerror=x>"), ""><img onerror=x>");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dates_and_lengths_read_as_people_write_them() {
|
||||||
|
assert_eq!(date(0), "1 Jan 1970");
|
||||||
|
assert_eq!(date(1_791_820_800), "12 Oct 2026");
|
||||||
|
assert_eq!(date(951_782_400), "29 Feb 2000");
|
||||||
|
assert_eq!(clock(59), "1 min");
|
||||||
|
assert_eq!(clock(3_725), "1 h 2 min");
|
||||||
|
}
|
||||||
|
}
|
||||||
593
crates/directory/src/store.rs
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
//! The directory's database: the feeds instances have sent, what each instance sent, and what
|
||||||
|
//! an admin has blocked. SQL written by hand, as ipx's is, so it runs on SQLite (tests) and
|
||||||
|
//! Postgres (production) both: `$1` parameters, `ON CONFLICT`, booleans as themselves.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use sea_orm::{ConnectionTrait, DatabaseConnection, Statement, TransactionTrait, Value};
|
||||||
|
|
||||||
|
use crate::{Blocked, FeedPage, Instance, Item, Listing, SnapshotEntry, SnapshotResult};
|
||||||
|
|
||||||
|
/// A feed's items as its page shows them, and as many as ipx's own Directory page showed.
|
||||||
|
pub const ITEMS: usize = 20;
|
||||||
|
|
||||||
|
/// How long a feed's failure goes unexplained: a site down for an hour is not called gone (ipx#143).
|
||||||
|
const FLAG_AFTER_SECS: i64 = 24 * 3600;
|
||||||
|
|
||||||
|
pub struct Store {
|
||||||
|
db: DatabaseConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the refresher needs to ask for a feed again.
|
||||||
|
pub struct Due {
|
||||||
|
pub id: String,
|
||||||
|
pub url: String,
|
||||||
|
pub etag: Option<String>,
|
||||||
|
pub last_modified: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a fetch found, to be stored.
|
||||||
|
pub enum Refreshed {
|
||||||
|
NotModified,
|
||||||
|
Feed {
|
||||||
|
parsed: ipx_feed::ParsedFeed,
|
||||||
|
etag: Option<String>,
|
||||||
|
last_modified: Option<String>,
|
||||||
|
},
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now() -> i64 {
|
||||||
|
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The address as every instance would write it: scheme and host lower-cased, a default port
|
||||||
|
/// and a fragment dropped. None for anything that is not http(s).
|
||||||
|
pub fn normalise(url: &str) -> Option<String> {
|
||||||
|
let mut u = url::Url::parse(url.trim()).ok()?;
|
||||||
|
if !matches!(u.scheme(), "http" | "https") || u.host_str().is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
u.set_fragment(None);
|
||||||
|
Some(u.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A feed's id: the first 64 bits of its normalised address's SHA-256, so every instance, and
|
||||||
|
/// anyone else, arrives at the same id for the same feed without asking.
|
||||||
|
pub fn feed_id(normalised: &str) -> String {
|
||||||
|
use sha2::Digest;
|
||||||
|
sha2::Sha256::digest(normalised.as_bytes())[..8].iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHEMA: &[&str] = &[
|
||||||
|
"CREATE TABLE IF NOT EXISTS feeds (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
url TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT,
|
||||||
|
image TEXT,
|
||||||
|
category TEXT,
|
||||||
|
subcategory TEXT,
|
||||||
|
description TEXT,
|
||||||
|
podcast BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
items TEXT,
|
||||||
|
etag TEXT,
|
||||||
|
last_modified TEXT,
|
||||||
|
checked_at BIGINT,
|
||||||
|
last_error TEXT,
|
||||||
|
error_since BIGINT
|
||||||
|
)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS snapshots (
|
||||||
|
instance TEXT NOT NULL,
|
||||||
|
feed_id TEXT NOT NULL,
|
||||||
|
subscribers BIGINT NOT NULL,
|
||||||
|
category TEXT,
|
||||||
|
subcategory TEXT,
|
||||||
|
PRIMARY KEY (instance, feed_id)
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS snapshots_feed ON snapshots (feed_id)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS blocked (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
url TEXT,
|
||||||
|
title TEXT,
|
||||||
|
at BIGINT NOT NULL
|
||||||
|
)",
|
||||||
|
];
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
/// A database of its own for a test: a fresh schema on IPX_TEST_DATABASE_URL's Postgres when
|
||||||
|
/// that is set, as ipx's tests do, or else SQLite in memory.
|
||||||
|
#[cfg(test)]
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
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
|
||||||
|
// tests running at once raced each other doing it.
|
||||||
|
let admin = Self { db: sea_orm::Database::connect(url.as_str()).await.unwrap() };
|
||||||
|
// Schemas an earlier run left behind: anything of ours not made by this process.
|
||||||
|
let old = admin
|
||||||
|
.rows(&format!(r"SELECT nspname FROM pg_namespace WHERE nspname LIKE 'ipxd\_%' AND nspname NOT LIKE 'ipxd\_{pid}\_%'"), vec![])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
for r in old {
|
||||||
|
let name: String = r.try_get_by_index(0).unwrap();
|
||||||
|
admin.db.execute_unprepared(&format!("DROP SCHEMA IF EXISTS {name} CASCADE")).await.unwrap();
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn open(url: &str) -> 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;
|
||||||
|
// asking for none keeps them out of the log, as ipx's connections do.
|
||||||
|
let sep = if url.contains('?') { '&' } else { '?' };
|
||||||
|
url = format!("{url}{sep}options=-c%20client_min_messages%3Dwarning");
|
||||||
|
}
|
||||||
|
let mut opts = sea_orm::ConnectOptions::new(url.clone());
|
||||||
|
opts.sqlx_logging(false);
|
||||||
|
// An in-memory SQLite database is one per connection: a pool of them would be several.
|
||||||
|
if url.contains(":memory:") {
|
||||||
|
opts.max_connections(1);
|
||||||
|
}
|
||||||
|
let db = sea_orm::Database::connect(opts).await?;
|
||||||
|
for sql in SCHEMA {
|
||||||
|
db.execute_unprepared(sql).await?;
|
||||||
|
}
|
||||||
|
// Added after the first deploy: a database made before has the table without them.
|
||||||
|
if db.execute_unprepared("SELECT category FROM snapshots WHERE false").await.is_err() {
|
||||||
|
db.execute_unprepared("ALTER TABLE snapshots ADD COLUMN category TEXT").await?;
|
||||||
|
db.execute_unprepared("ALTER TABLE snapshots ADD COLUMN subcategory TEXT").await?;
|
||||||
|
}
|
||||||
|
Ok(Self { db })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stmt(&self, sql: &str, values: Vec<Value>) -> Statement {
|
||||||
|
Statement::from_sql_and_values(self.db.get_database_backend(), sql, values)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exec(&self, sql: &str, values: Vec<Value>) -> Result<u64> {
|
||||||
|
Ok(self.db.execute_raw(self.stmt(sql, values)).await?.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rows(&self, sql: &str, values: Vec<Value>) -> Result<Vec<sea_orm::QueryResult>> {
|
||||||
|
Ok(self.db.query_all_raw(self.stmt(sql, values)).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ping(&self) -> Result<()> {
|
||||||
|
self.rows("SELECT 1", vec![]).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces everything `instance` sent before with `entries`. A private-looking address or a
|
||||||
|
/// blocked feed is left out and counted; the rest are added to the feeds, new ones to be
|
||||||
|
/// fetched by the refresher. Feeds no instance sends any more go.
|
||||||
|
pub async fn snapshot(&self, instance: &str, entries: &[SnapshotEntry]) -> Result<SnapshotResult> {
|
||||||
|
let blocked: std::collections::HashSet<String> =
|
||||||
|
self.rows("SELECT id FROM blocked", vec![]).await?.iter().map(|r| r.try_get("", "id")).collect::<Result<_, _>>()?;
|
||||||
|
let mut result = SnapshotResult::default();
|
||||||
|
let mut keep = std::collections::BTreeMap::new();
|
||||||
|
for e in entries {
|
||||||
|
let Some(url) = normalise(&e.url) else {
|
||||||
|
result.refused_invalid += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Checked here as well as by the sender: one instance's miss must not publish
|
||||||
|
// someone's paid feed to every other.
|
||||||
|
if ipx_feed::url_looks_private(&url) {
|
||||||
|
result.refused_private += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let id = feed_id(&url);
|
||||||
|
if blocked.contains(&id) {
|
||||||
|
result.refused_blocked += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The same feed twice in one snapshot counts once, at its larger number.
|
||||||
|
let n = e.subscribers.max(0);
|
||||||
|
let path = e.category.as_deref().map(str::trim).filter(|c| !c.is_empty()).map(ipx_feed::category_path);
|
||||||
|
let slot = keep.entry(id).or_insert((url, n, path.clone()));
|
||||||
|
slot.1 = slot.1.max(n);
|
||||||
|
if slot.2.is_none() {
|
||||||
|
slot.2 = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.accepted = keep.len();
|
||||||
|
|
||||||
|
let tx = self.db.begin().await?;
|
||||||
|
let backend = tx.get_database_backend();
|
||||||
|
let run = |sql: &str, values: Vec<Value>| Statement::from_sql_and_values(backend, sql, values);
|
||||||
|
tx.execute_raw(run("DELETE FROM snapshots WHERE instance = $1", vec![instance.into()])).await?;
|
||||||
|
for (id, (url, n, path)) in &keep {
|
||||||
|
tx.execute_raw(run(
|
||||||
|
"INSERT INTO feeds (id, url) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING",
|
||||||
|
vec![id.clone().into(), url.clone().into()],
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
tx.execute_raw(run(
|
||||||
|
"INSERT INTO snapshots (instance, feed_id, subscribers, category, subcategory) VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
vec![
|
||||||
|
instance.into(),
|
||||||
|
id.clone().into(),
|
||||||
|
(*n).into(),
|
||||||
|
path.as_ref().map(|p| p.0.clone()).into(),
|
||||||
|
path.as_ref().and_then(|p| p.1.clone()).into(),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
tx.execute_raw(run("DELETE FROM feeds WHERE id NOT IN (SELECT feed_id FROM snapshots)", vec![])).await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops the snapshots of every instance not in `names`: a token taken out of the config
|
||||||
|
/// file takes its counts with it.
|
||||||
|
pub async fn keep_instances(&self, names: &[String]) -> Result<u64> {
|
||||||
|
let mut dropped = 0;
|
||||||
|
let present: Vec<String> = self
|
||||||
|
.rows("SELECT DISTINCT instance FROM snapshots", vec![])
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.try_get("", "instance"))
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
for name in present.iter().filter(|n| !names.contains(n)) {
|
||||||
|
dropped += self.drop_instance(name).await?;
|
||||||
|
}
|
||||||
|
Ok(dropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops what one instance sent; how many feeds it had.
|
||||||
|
pub async fn drop_instance(&self, name: &str) -> Result<u64> {
|
||||||
|
let n = self.exec("DELETE FROM snapshots WHERE instance = $1", vec![name.into()]).await?;
|
||||||
|
self.exec("DELETE FROM feeds WHERE id NOT IN (SELECT feed_id FROM snapshots)", vec![]).await?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The feed's own category, or else one a sending server's admin gave it.
|
||||||
|
// Only feeds read as a feed at least once (`items` set): never an OPML a server sent before it
|
||||||
|
// knew it was one, nor an address that has never worked.
|
||||||
|
const LISTING: &str = "SELECT f.id, f.url, f.title, f.image,
|
||||||
|
CASE WHEN f.category IS NULL THEN max(s.category) ELSE f.category END AS category,
|
||||||
|
CASE WHEN f.category IS NULL THEN max(s.subcategory) ELSE f.subcategory END AS subcategory,
|
||||||
|
f.podcast, CAST(sum(s.subscribers) AS BIGINT) AS subscribers
|
||||||
|
FROM feeds f JOIN snapshots s ON s.feed_id = f.id WHERE f.items IS NOT NULL";
|
||||||
|
|
||||||
|
fn listing(r: &sea_orm::QueryResult) -> Result<Listing> {
|
||||||
|
Ok(Listing {
|
||||||
|
id: r.try_get("", "id")?,
|
||||||
|
url: r.try_get("", "url")?,
|
||||||
|
title: r.try_get("", "title")?,
|
||||||
|
image: r.try_get("", "image")?,
|
||||||
|
category: r.try_get("", "category")?,
|
||||||
|
subcategory: r.try_get("", "subcategory")?,
|
||||||
|
podcast: r.try_get("", "podcast")?,
|
||||||
|
subscribers: r.try_get("", "subscribers")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every feed, A to Z by title (one not fetched yet has none and sorts by its address).
|
||||||
|
///
|
||||||
|
/// ponytail: the whole list in one answer, as ipx's own Directory was; page it when it
|
||||||
|
/// grows past a few thousand feeds.
|
||||||
|
pub async fn list(&self) -> Result<Vec<Listing>> {
|
||||||
|
let sql = format!("{} GROUP BY f.id ORDER BY lower(coalesce(f.title, f.url)), f.id", Self::LISTING);
|
||||||
|
self.rows(&sql, vec![]).await?.iter().map(Self::listing).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most subscribed, most first.
|
||||||
|
pub async fn popular(&self, limit: u64) -> Result<Vec<Listing>> {
|
||||||
|
let sql = format!(
|
||||||
|
"{} GROUP BY f.id ORDER BY sum(s.subscribers) DESC, lower(coalesce(f.title, f.url)), f.id LIMIT $1",
|
||||||
|
Self::LISTING
|
||||||
|
);
|
||||||
|
self.rows(&sql, vec![(limit as i64).into()]).await?.iter().map(Self::listing).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One feed with its description and latest items.
|
||||||
|
pub async fn feed(&self, id: &str) -> Result<Option<FeedPage>> {
|
||||||
|
let sql = format!("{} AND f.id = $1 GROUP BY f.id", Self::LISTING);
|
||||||
|
let Some(r) = self.rows(&sql, vec![id.into()]).await?.into_iter().next() else { return Ok(None) };
|
||||||
|
let listing = Self::listing(&r)?;
|
||||||
|
let r = self
|
||||||
|
.rows("SELECT description, items, last_error, error_since FROM feeds WHERE id = $1", vec![id.into()])
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("feed {id} went while it was read"))?;
|
||||||
|
let items: Option<String> = r.try_get("", "items")?;
|
||||||
|
let last_error: Option<String> = r.try_get("", "last_error")?;
|
||||||
|
let error_since: Option<i64> = r.try_get("", "error_since")?;
|
||||||
|
Ok(Some(FeedPage {
|
||||||
|
listing,
|
||||||
|
description: r.try_get("", "description")?,
|
||||||
|
failing: last_error.map(|e| failure_words(&e, now() - error_since.unwrap_or_else(now))),
|
||||||
|
items: items.and_then(|i| serde_json::from_str(&i).ok()).unwrap_or_default(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a feed and blocks it, so no snapshot brings it back. False when it was neither
|
||||||
|
/// listed nor blocked already.
|
||||||
|
pub async fn block(&self, id: &str) -> Result<bool> {
|
||||||
|
let r = self.rows("SELECT url, title FROM feeds WHERE id = $1", vec![id.into()]).await?.into_iter().next();
|
||||||
|
let (url, title): (Option<String>, Option<String>) = match &r {
|
||||||
|
Some(r) => (r.try_get("", "url")?, r.try_get("", "title")?),
|
||||||
|
None => (None, None),
|
||||||
|
};
|
||||||
|
let added = self
|
||||||
|
.exec(
|
||||||
|
"INSERT INTO blocked (id, url, title, at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING",
|
||||||
|
vec![id.into(), url.into(), title.into(), now().into()],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
self.exec("DELETE FROM snapshots WHERE feed_id = $1", vec![id.into()]).await?;
|
||||||
|
let removed = self.exec("DELETE FROM feeds WHERE id = $1", vec![id.into()]).await?;
|
||||||
|
Ok(added + removed > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unblocks a feed; it comes back with the next snapshot that has it.
|
||||||
|
pub async fn unblock(&self, id: &str) -> Result<bool> {
|
||||||
|
Ok(self.exec("DELETE FROM blocked WHERE id = $1", vec![id.into()]).await? > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn instances(&self) -> Result<Vec<Instance>> {
|
||||||
|
// Postgres sums a BIGINT as NUMERIC, which does not read as an i64.
|
||||||
|
self.rows(
|
||||||
|
"SELECT instance, count(*) AS feeds, CAST(sum(subscribers) AS BIGINT) AS subscribers
|
||||||
|
FROM snapshots GROUP BY instance ORDER BY instance",
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|r| Ok(Instance { name: r.try_get("", "instance")?, feeds: r.try_get("", "feeds")?, subscribers: r.try_get("", "subscribers")? }))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn blocked(&self) -> Result<Vec<Blocked>> {
|
||||||
|
self.rows("SELECT id, url, title, at FROM blocked ORDER BY at DESC, id", vec![])
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|r| Ok(Blocked { id: r.try_get("", "id")?, url: r.try_get("", "url")?, title: r.try_get("", "title")?, at: r.try_get("", "at")? }))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feeds never fetched, then those fetched longest ago, older than `every` seconds.
|
||||||
|
pub async fn due(&self, every: i64, limit: u64) -> Result<Vec<Due>> {
|
||||||
|
self.rows(
|
||||||
|
"SELECT id, url, etag, last_modified FROM feeds
|
||||||
|
WHERE checked_at IS NULL OR checked_at < $1
|
||||||
|
ORDER BY coalesce(checked_at, 0), id LIMIT $2",
|
||||||
|
vec![(now() - every).into(), (limit as i64).into()],
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
Ok(Due {
|
||||||
|
id: r.try_get("", "id")?,
|
||||||
|
url: r.try_get("", "url")?,
|
||||||
|
etag: r.try_get("", "etag")?,
|
||||||
|
last_modified: r.try_get("", "last_modified")?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn due_one(&self, id: &str) -> Result<Option<Due>> {
|
||||||
|
Ok(self
|
||||||
|
.rows("SELECT id, url, etag, last_modified FROM feeds WHERE id = $1", vec![id.into()])
|
||||||
|
.await?
|
||||||
|
.first()
|
||||||
|
.map(|r| -> Result<Due> {
|
||||||
|
Ok(Due {
|
||||||
|
id: r.try_get("", "id")?,
|
||||||
|
url: r.try_get("", "url")?,
|
||||||
|
etag: r.try_get("", "etag")?,
|
||||||
|
last_modified: r.try_get("", "last_modified")?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save(&self, id: &str, got: Refreshed) -> Result<()> {
|
||||||
|
let t = now();
|
||||||
|
match got {
|
||||||
|
Refreshed::NotModified => {
|
||||||
|
self.exec("UPDATE feeds SET checked_at = $1, last_error = NULL, error_since = NULL WHERE id = $2", vec![t.into(), id.into()]).await?;
|
||||||
|
}
|
||||||
|
Refreshed::Failed(err) => {
|
||||||
|
self.exec(
|
||||||
|
"UPDATE feeds SET checked_at = $1, last_error = $2, error_since = coalesce(error_since, $1) WHERE id = $3",
|
||||||
|
vec![t.into(), err.into(), id.into()],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Refreshed::Feed { parsed, etag, last_modified } => {
|
||||||
|
let path = parsed.category.as_deref().map(ipx_feed::category_path);
|
||||||
|
let podcast = parsed.entries.iter().flat_map(|e| &e.enclosures).any(|e| {
|
||||||
|
e.mime.as_deref().is_some_and(|m| m.starts_with("audio/") || m.starts_with("video/"))
|
||||||
|
});
|
||||||
|
let mut entries = parsed.entries;
|
||||||
|
entries.sort_by_key(|e| std::cmp::Reverse(e.published));
|
||||||
|
// What an item is, never its files: their addresses are where a key would be.
|
||||||
|
let items: Vec<Item> = entries
|
||||||
|
.into_iter()
|
||||||
|
.take(ITEMS)
|
||||||
|
.map(|e| Item { guid: e.guid, title: e.title, link: e.link, published: e.published, duration: e.duration, description: e.description })
|
||||||
|
.collect();
|
||||||
|
self.exec(
|
||||||
|
"UPDATE feeds SET title = $1, image = $2, category = $3, subcategory = $4, description = $5,
|
||||||
|
podcast = $6, items = $7, etag = $8, last_modified = $9, checked_at = $10,
|
||||||
|
last_error = NULL, error_since = NULL
|
||||||
|
WHERE id = $11",
|
||||||
|
vec![
|
||||||
|
parsed.title.into(),
|
||||||
|
parsed.image.into(),
|
||||||
|
path.as_ref().map(|p| p.0.clone()).into(),
|
||||||
|
path.and_then(|p| p.1).into(),
|
||||||
|
parsed.description.into(),
|
||||||
|
podcast.into(),
|
||||||
|
serde_json::to_string(&items)?.into(),
|
||||||
|
etag.into(),
|
||||||
|
last_modified.into(),
|
||||||
|
t.into(),
|
||||||
|
id.into(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A feed's last error as its page says it: explained once it has been failing a day, or else
|
||||||
|
/// only that the check failed. Never the error itself, which is for the log.
|
||||||
|
fn failure_words(err: &str, failing_for: i64) -> String {
|
||||||
|
Some(err)
|
||||||
|
.filter(|_| failing_for >= FLAG_AFTER_SECS)
|
||||||
|
.and_then(ipx_feed::explain_failure)
|
||||||
|
.map_or_else(|| "Its last check failed.".to_owned(), |f| f.reason.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Reads the fixture feed as every feed waiting to be fetched, as the refresher would.
|
||||||
|
pub(crate) async fn fetch_all(s: &Store) {
|
||||||
|
for d in s.due(0, 100).await.unwrap() {
|
||||||
|
let parsed = ipx_feed::parse(include_bytes!("../../../tests/data/rss2.xml")).unwrap();
|
||||||
|
s.save(&d.id, Refreshed::Feed { parsed, etag: None, last_modified: None }).await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn e(url: &str, n: i64) -> SnapshotEntry {
|
||||||
|
SnapshotEntry { url: url.into(), subscribers: n, category: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failure_is_explained_after_a_day_and_never_names_its_address() {
|
||||||
|
let nothing = "the feed's address answered with nothing at all";
|
||||||
|
assert_eq!(failure_words(nothing, FLAG_AFTER_SECS), "This address answers with nothing; the site may be gone.");
|
||||||
|
// Not before it has been failing a day, as on the feed's own page in ipx (#143).
|
||||||
|
assert_eq!(failure_words(nothing, 3600), "Its last check failed.");
|
||||||
|
let raw = "error sending request for url (http://daily-quests.com/comic/?feed=atom): connection closed";
|
||||||
|
assert_eq!(failure_words(raw, FLAG_AFTER_SECS), "Its last check failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_instance_writes_one_feed_the_same_way() {
|
||||||
|
let a = normalise("HTTPS://Example.COM:443/feed.xml#top").unwrap();
|
||||||
|
assert_eq!(a, "https://example.com/feed.xml");
|
||||||
|
assert_eq!(feed_id(&a), feed_id(&normalise("https://example.com/feed.xml").unwrap()));
|
||||||
|
assert_eq!(feed_id(&a).len(), 16);
|
||||||
|
assert!(normalise("ftp://example.com/x").is_none());
|
||||||
|
assert!(normalise("not a url").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn snapshots_replace_what_an_instance_sent_and_add_up_across_instances() {
|
||||||
|
let s = Store::scratch().await;
|
||||||
|
let r = s
|
||||||
|
.snapshot("a", &[e("https://x.test/a.xml", 3), e("https://x.test/b.xml", 1), e("https://x.test/a.xml", 5)])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(r.accepted, 2);
|
||||||
|
s.snapshot("b", &[e("https://x.test/a.xml", 2)]).await.unwrap();
|
||||||
|
// Nothing is listed until it has been read as a feed.
|
||||||
|
assert!(s.list().await.unwrap().is_empty());
|
||||||
|
fetch_all(&s).await;
|
||||||
|
let pop = s.popular(10).await.unwrap();
|
||||||
|
assert_eq!((pop[0].url.as_str(), pop[0].subscribers), ("https://x.test/a.xml", 7));
|
||||||
|
assert_eq!(pop[1].subscribers, 1);
|
||||||
|
|
||||||
|
// a no longer has b.xml: nobody does, so it goes.
|
||||||
|
s.snapshot("a", &[e("https://x.test/a.xml", 1)]).await.unwrap();
|
||||||
|
let all = s.list().await.unwrap();
|
||||||
|
assert_eq!(all.len(), 1);
|
||||||
|
assert_eq!(all[0].subscribers, 3);
|
||||||
|
|
||||||
|
let i = s.instances().await.unwrap();
|
||||||
|
assert_eq!(i.iter().map(|i| (i.name.as_str(), i.feeds, i.subscribers)).collect::<Vec<_>>(), [("a", 1, 1), ("b", 1, 2)]);
|
||||||
|
|
||||||
|
// A token taken out of the config file takes its counts.
|
||||||
|
s.keep_instances(&["b".into()]).await.unwrap();
|
||||||
|
assert_eq!(s.list().await.unwrap()[0].subscribers, 2);
|
||||||
|
s.drop_instance("b").await.unwrap();
|
||||||
|
assert!(s.list().await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn private_looking_addresses_and_blocked_feeds_are_refused() {
|
||||||
|
let s = Store::scratch().await;
|
||||||
|
let r = s
|
||||||
|
.snapshot(
|
||||||
|
"a",
|
||||||
|
&[
|
||||||
|
e("https://feeds.supercast.com/feeds/abc123", 1),
|
||||||
|
e("https://x.test/feed?token=secret", 1),
|
||||||
|
e("https://user:pass@x.test/feed", 1),
|
||||||
|
e("https://www.patreon.com/rss/show?auth=k", 1),
|
||||||
|
e("mailto:x@y", 1),
|
||||||
|
e("https://x.test/public.xml", 1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!((r.accepted, r.refused_private, r.refused_invalid), (1, 4, 1));
|
||||||
|
fetch_all(&s).await;
|
||||||
|
|
||||||
|
let id = s.list().await.unwrap()[0].id.clone();
|
||||||
|
assert!(s.block(&id).await.unwrap());
|
||||||
|
assert!(s.list().await.unwrap().is_empty());
|
||||||
|
let r = s.snapshot("a", &[e("https://x.test/public.xml", 1)]).await.unwrap();
|
||||||
|
assert_eq!((r.accepted, r.refused_blocked), (0, 1));
|
||||||
|
assert_eq!(s.blocked().await.unwrap()[0].url.as_deref(), Some("https://x.test/public.xml"));
|
||||||
|
assert!(s.unblock(&id).await.unwrap());
|
||||||
|
assert_eq!(s.snapshot("a", &[e("https://x.test/public.xml", 1)]).await.unwrap().accepted, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_feed_naming_no_category_takes_the_one_a_server_gave_it() {
|
||||||
|
let s = Store::scratch().await;
|
||||||
|
let entry = |c: Option<&str>| SnapshotEntry { url: "https://x.test/blog.xml".into(), subscribers: 1, category: c.map(Into::into) };
|
||||||
|
s.snapshot("a", &[entry(Some("Visual Arts"))]).await.unwrap();
|
||||||
|
let d = s.due(0, 10).await.unwrap().remove(0);
|
||||||
|
let mut parsed = ipx_feed::parse(include_bytes!("../../../tests/data/rss2.xml")).unwrap();
|
||||||
|
parsed.category = None;
|
||||||
|
s.save(&d.id, Refreshed::Feed { parsed, etag: None, last_modified: None }).await.unwrap();
|
||||||
|
let l = s.list().await.unwrap().remove(0);
|
||||||
|
assert_eq!((l.category.as_deref(), l.subcategory.as_deref()), (Some("Arts"), Some("Visual Arts")));
|
||||||
|
s.snapshot("a", &[entry(None)]).await.unwrap();
|
||||||
|
assert_eq!(s.list().await.unwrap()[0].category, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_fetched_feed_shows_its_metadata_and_newest_items_but_no_files() {
|
||||||
|
let s = Store::scratch().await;
|
||||||
|
s.snapshot("a", &[e("https://x.test/feed.xml", 1)]).await.unwrap();
|
||||||
|
let due = s.due(3600, 10).await.unwrap();
|
||||||
|
assert_eq!(due.len(), 1);
|
||||||
|
let parsed = ipx_feed::parse(include_bytes!("../../../tests/data/rss2.xml")).unwrap();
|
||||||
|
let title = parsed.title.clone();
|
||||||
|
s.save(&due[0].id, Refreshed::Feed { parsed, etag: Some("e".into()), last_modified: None }).await.unwrap();
|
||||||
|
assert!(s.due(3600, 10).await.unwrap().is_empty());
|
||||||
|
let page = s.feed(&due[0].id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(page.listing.title, title);
|
||||||
|
assert!(page.listing.podcast);
|
||||||
|
assert!(!page.items.is_empty() && page.items.len() <= ITEMS);
|
||||||
|
assert!(page.items.windows(2).all(|w| w[0].published >= w[1].published));
|
||||||
|
assert!(page.failing.is_none());
|
||||||
|
let json = serde_json::to_string(&page).unwrap();
|
||||||
|
assert!(!json.contains(".mp3"), "an item's file must not be in its page: {json}");
|
||||||
|
|
||||||
|
s.save(&due[0].id, Refreshed::Failed("HTTP 404 Not Found".into())).await.unwrap();
|
||||||
|
let page = s.feed(&due[0].id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(page.failing.as_deref(), Some("Its last check failed."));
|
||||||
|
assert_eq!(page.listing.title, title, "a failure keeps what was found before");
|
||||||
|
}
|
||||||
|
}
|
||||||
20
crates/feed/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "ipx-feed"
|
||||||
|
version = "0.0.0"
|
||||||
|
publish = false
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
atom_syndication.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
opml.workspace = true
|
||||||
|
quick-xml.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
rss.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
url.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio.workspace = true
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
//! Feed fetching and parsing. Replaces FeedData.__getFeed / __getEntries.
|
//! Feed fetching and parsing, shared by ipx and the directory (#202). Replaces
|
||||||
|
//! FeedData.__getFeed / __getEntries.
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED};
|
use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED};
|
||||||
|
|
||||||
use crate::config::Feed as FeedCfg;
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct ParsedFeed {
|
pub struct ParsedFeed {
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub ttl_mins: Option<u64>,
|
pub ttl_mins: Option<u64>,
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
/// The channel's first `<itunes:category>`, for the Directory's chips.
|
/// The site the feed belongs to, where a favicon can stand in for missing artwork.
|
||||||
|
pub site: Option<String>,
|
||||||
|
/// The channel's first `<itunes:category>`, for the Directory's categories.
|
||||||
pub category: Option<String>,
|
pub category: Option<String>,
|
||||||
|
/// What the feed says it is, for its page in the Directory (#130): RSS's `<description>`,
|
||||||
|
/// or iTunes' summary where that is empty, or Atom's subtitle. HTML as often as not.
|
||||||
|
pub description: Option<String>,
|
||||||
pub entries: Vec<Entry>,
|
pub entries: Vec<Entry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,42 +55,74 @@ pub enum Fetched {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Conditional GET. reqwest handles gzip and redirects; the original's hand-rolled
|
/// The longest a feed may take, connecting to the last byte: a scan handles feeds in order, and
|
||||||
/// CONNECT/socket.ssl proxy path is gone -- `system-proxy` reads http_proxy/https_proxy.
|
/// with no limit one hung server held every scan for as long as it did. Dreamwidth answered 504
|
||||||
|
/// after 60-67 s for a day, and each scan took 70-80 s instead of 15 (#108). A feed is small;
|
||||||
|
/// downloads, which are not, have no such limit.
|
||||||
|
pub const FEED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// 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))]
|
||||||
pub async fn fetch(
|
pub async fn fetch(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
cfg: &FeedCfg,
|
feed_url: &str,
|
||||||
|
auth: Option<(&str, Option<String>)>,
|
||||||
etag: Option<&str>,
|
etag: Option<&str>,
|
||||||
last_modified: Option<&str>,
|
last_modified: Option<&str>,
|
||||||
) -> Result<Fetched> {
|
) -> Result<(Fetched, Option<String>)> {
|
||||||
let mut req = client.get(&cfg.url);
|
let start = reqwest::Url::parse(feed_url).context("the feed's address")?;
|
||||||
|
let mut url = start.clone();
|
||||||
|
let mut permanent = true;
|
||||||
|
for _ in 0..10 {
|
||||||
|
let mut req = client.get(url.clone()).timeout(FEED_TIMEOUT);
|
||||||
if let Some(tag) = etag {
|
if let Some(tag) = etag {
|
||||||
req = req.header(IF_NONE_MATCH, tag);
|
req = req.header(IF_NONE_MATCH, tag);
|
||||||
}
|
}
|
||||||
if let Some(lm) = last_modified {
|
if let Some(lm) = last_modified {
|
||||||
req = req.header(IF_MODIFIED_SINCE, lm);
|
req = req.header(IF_MODIFIED_SINCE, lm);
|
||||||
}
|
}
|
||||||
if let Some(user) = &cfg.username {
|
// The feed's own host only: a redirect elsewhere must not be handed the password.
|
||||||
req = req.basic_auth(user, cfg.password());
|
if let Some((user, password)) = &auth
|
||||||
|
&& url.host_str() == start.host_str()
|
||||||
|
{
|
||||||
|
req = req.basic_auth(user, password.as_deref());
|
||||||
}
|
}
|
||||||
|
|
||||||
let resp = req.send().await.context("connecting")?;
|
let resp = req.send().await.context("connecting")?;
|
||||||
if resp.status() == StatusCode::NOT_MODIFIED {
|
|
||||||
return Ok(Fetched::NotModified);
|
|
||||||
}
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
|
if status.is_redirection() && status != StatusCode::NOT_MODIFIED {
|
||||||
|
let to = resp
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::LOCATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.ok_or_else(|| anyhow!("HTTP {status} without a Location to go to"))?;
|
||||||
|
url = url.join(to).with_context(|| format!("redirected to {to:?}, which is not an address"))?;
|
||||||
|
permanent &= matches!(status, StatusCode::MOVED_PERMANENTLY | StatusCode::PERMANENT_REDIRECT);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let moved = (permanent && url != start).then(|| url.to_string());
|
||||||
|
if status == StatusCode::NOT_MODIFIED {
|
||||||
|
return Ok((Fetched::NotModified, moved));
|
||||||
|
}
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
// The original surfaced 401/407 specially; the code is enough for a UI to switch on.
|
// The original surfaced 401/407 specially; the code is enough for a UI to switch on.
|
||||||
return Err(anyhow!("HTTP {status}"));
|
return Err(anyhow!("HTTP {status}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let header = |h: reqwest::header::HeaderName| {
|
let header = |h: reqwest::header::HeaderName| {
|
||||||
resp.headers().get(&h).and_then(|v| v.to_str().ok()).map(str::to_owned)
|
resp.headers().get(&h).and_then(|v| v.to_str().ok()).map(str::to_owned)
|
||||||
};
|
};
|
||||||
let etag = header(ETAG);
|
let etag = header(ETAG);
|
||||||
let last_modified = header(LAST_MODIFIED);
|
let last_modified = header(LAST_MODIFIED);
|
||||||
let bytes = resp.bytes().await.context("reading body")?.to_vec();
|
let bytes = resp.bytes().await.context("reading body")?.to_vec();
|
||||||
Ok(Fetched::Body { bytes, etag, last_modified })
|
return Ok((Fetched::Body { bytes, etag, last_modified }, moved));
|
||||||
|
}
|
||||||
|
// Worded as reqwest worded it, which failure_kind reads as a redirect loop.
|
||||||
|
Err(anyhow!("error following redirect for url ({url}): too many redirects"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A stored `last_error`, translated into plain words for whoever subscribes: whose problem
|
/// A stored `last_error`, translated into plain words for whoever subscribes: whose problem
|
||||||
@@ -96,6 +132,37 @@ pub struct Failure {
|
|||||||
pub new_url: Option<String>,
|
pub new_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A failure's kind, for the log's `error.type` (#91): the HTTP status where there is one, as
|
||||||
|
/// OpenTelemetry names an HTTP error, and otherwise a word for what went wrong. Matches the
|
||||||
|
/// same wording as `explain_failure`; a message it does not know is "other", never a wrong kind.
|
||||||
|
pub fn failure_kind(msg: &str) -> (String, Option<u16>) {
|
||||||
|
let low = msg.to_ascii_lowercase();
|
||||||
|
let code = low.split("http ").skip(1).find_map(|r| r.get(..3)?.parse::<u16>().ok());
|
||||||
|
if let Some(c) = code.filter(|c| (100..600).contains(c)) {
|
||||||
|
return (c.to_string(), Some(c));
|
||||||
|
}
|
||||||
|
let kind = if low.contains("dns error") || low.contains("failed to lookup address") || low.contains("no address associated") {
|
||||||
|
"dns"
|
||||||
|
} else if low.contains("too many redirects") {
|
||||||
|
"redirect_loop"
|
||||||
|
} else if low.contains("timed out") || low.contains("timeout") {
|
||||||
|
"timeout"
|
||||||
|
} else if low.contains("certificate") || low.contains("tls") {
|
||||||
|
"tls"
|
||||||
|
} else if low.contains("got a web page") {
|
||||||
|
"not_a_feed"
|
||||||
|
} else if low.contains("the site sent ") {
|
||||||
|
"site_message"
|
||||||
|
} else if low.contains("connect") {
|
||||||
|
"connect"
|
||||||
|
} else if low.contains("pars") {
|
||||||
|
"parse"
|
||||||
|
} else {
|
||||||
|
"other"
|
||||||
|
};
|
||||||
|
(kind.into(), None)
|
||||||
|
}
|
||||||
|
|
||||||
/// Reads a `last_error` the same way `set_feed_error` received it (`format!("{e:#}")` on the
|
/// Reads a `last_error` the same way `set_feed_error` received it (`format!("{e:#}")` on the
|
||||||
/// anyhow chain from `fetch` or `parse`) and says what it means, for the errors worth telling
|
/// anyhow chain from `fetch` or `parse`) and says what it means, for the errors worth telling
|
||||||
/// someone about. Everything else -- a timeout, a 5xx, a 429, a feed that is simply garbled --
|
/// someone about. Everything else -- a timeout, a 5xx, a 429, a feed that is simply garbled --
|
||||||
@@ -124,18 +191,31 @@ pub fn explain_failure(msg: &str) -> Option<Failure> {
|
|||||||
if low.contains("http 401") || low.contains("http 403") {
|
if low.contains("http 401") || low.contains("http 403") {
|
||||||
return Some(Failure { reason: "The site refuses ipx's requests.", new_url: None });
|
return Some(Failure { reason: "The site refuses ipx's requests.", new_url: None });
|
||||||
}
|
}
|
||||||
|
if low.contains("answered with nothing") {
|
||||||
|
return Some(Failure { reason: "This address answers with nothing; the site may be gone.", new_url: None });
|
||||||
|
}
|
||||||
if low.contains("http 402") {
|
if low.contains("http 402") {
|
||||||
return Some(Failure { reason: "The feed now needs a paid plan.", new_url: None });
|
return Some(Failure { reason: "The feed now needs a paid plan.", new_url: None });
|
||||||
}
|
}
|
||||||
if low.contains("dns error")
|
// Only a name that does not exist. A lookup that failed for the moment says nothing about the
|
||||||
|| low.contains("failed to lookup address")
|
// site, and NPR's and Megaphone's feeds were labelled gone after a DNS outage (#143).
|
||||||
|| low.contains("no address associated")
|
if !is_temporary_lookup_failure(msg)
|
||||||
|
&& (low.contains("dns error") || low.contains("failed to lookup address") || low.contains("no address associated"))
|
||||||
{
|
{
|
||||||
return Some(Failure { reason: "This address no longer resolves; the site is gone.", new_url: None });
|
return Some(Failure { reason: "This address no longer resolves; the site may be gone.", new_url: None });
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A host that could not be looked up for the moment -- the resolver's EAI_AGAIN, glibc's
|
||||||
|
/// "Temporary failure in name resolution", musl's "Try again" -- as against a name that does
|
||||||
|
/// not exist ("Name or service not known"). It says the network was not answering, not that the
|
||||||
|
/// feed is gone (#143).
|
||||||
|
pub fn is_temporary_lookup_failure(msg: &str) -> bool {
|
||||||
|
let low = msg.to_ascii_lowercase();
|
||||||
|
low.contains("dns error") && (low.contains("temporary failure in name resolution") || low.contains("try again"))
|
||||||
|
}
|
||||||
|
|
||||||
/// True when a body is an OPML document rather than a feed.
|
/// True when a body is an OPML document rather than a feed.
|
||||||
///
|
///
|
||||||
/// The original matched on the URL ending in ".opml" (iPXClass.py:34), which misses an
|
/// The original matched on the URL ending in ".opml" (iPXClass.py:34), which misses an
|
||||||
@@ -152,10 +232,21 @@ pub fn parse_opml(bytes: &[u8]) -> Result<Vec<(String, String)>> {
|
|||||||
let doc = opml::OPML::from_str(&text)
|
let doc = opml::OPML::from_str(&text)
|
||||||
.map_err(|e| anyhow!("that does not parse as OPML: {e}"))?;
|
.map_err(|e| anyhow!("that does not parse as OPML: {e}"))?;
|
||||||
let mut out = vec![];
|
let mut out = vec![];
|
||||||
crate::collect_outlines(&doc.body.outlines, &mut out);
|
collect_outlines(&doc.body.outlines, &mut out);
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OPML nests feeds inside folder outlines, so this walks the whole tree.
|
||||||
|
pub fn collect_outlines(outlines: &[opml::Outline], out: &mut Vec<(String, String)>) {
|
||||||
|
for o in outlines {
|
||||||
|
if let Some(url) = &o.xml_url {
|
||||||
|
let title = o.title.clone().unwrap_or_else(|| o.text.clone());
|
||||||
|
out.push((title, url.clone()));
|
||||||
|
}
|
||||||
|
collect_outlines(&o.outlines, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The <head><title> of an OPML document.
|
/// The <head><title> of an OPML document.
|
||||||
pub fn opml_title(bytes: &[u8]) -> Option<String> {
|
pub fn opml_title(bytes: &[u8]) -> Option<String> {
|
||||||
let text = String::from_utf8_lossy(bytes);
|
let text = String::from_utf8_lossy(bytes);
|
||||||
@@ -185,11 +276,20 @@ pub fn is_patreon_creator(url: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// What was typed into Add feed, as a URL. A bare Patreon token is taken as its creator's
|
/// What was typed into Add feed, as a URL. A bare Patreon token is taken as its creator's
|
||||||
/// feed, since the token alone says whose it is.
|
/// feed, since the token alone says whose it is. An address with no scheme is https: 'cnn.com'
|
||||||
|
/// was stored as typed and every check failed with "relative URL without a base" (#101).
|
||||||
pub fn expand_input(input: &str) -> String {
|
pub fn expand_input(input: &str) -> String {
|
||||||
let s = input.trim();
|
let s = input.trim();
|
||||||
let token = s.len() >= 20 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
|
let token = s.len() >= 20 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
|
||||||
if token { format!("https://www.patreon.com/rss?auth={s}") } else { s.to_owned() }
|
if token {
|
||||||
|
format!("https://www.patreon.com/rss?auth={s}")
|
||||||
|
} else if let Some(rest) = s.strip_prefix("//") {
|
||||||
|
format!("https://{rest}")
|
||||||
|
} else if !s.contains("://") {
|
||||||
|
format!("https://{s}")
|
||||||
|
} else {
|
||||||
|
s.to_owned()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether two URLs are the same feed. One Patreon show has several spellings -- by the
|
/// Whether two URLs are the same feed. One Patreon show has several spellings -- by the
|
||||||
@@ -210,7 +310,7 @@ pub async fn patreon_shows(
|
|||||||
) -> Result<(Option<String>, Vec<(String, String)>)> {
|
) -> Result<(Option<String>, Vec<(String, String)>)> {
|
||||||
// The creator feed names its campaign by number in its self link, a few hundred bytes in.
|
// The creator feed names its campaign by number in its self link, a few hundred bytes in.
|
||||||
// The whole feed runs to megabytes and Patreon ignores Range, so read until it turns up.
|
// The whole feed runs to megabytes and Patreon ignores Range, so read until it turns up.
|
||||||
let mut resp = client.get(url).send().await.context("connecting")?;
|
let mut resp = client.get(url).timeout(FEED_TIMEOUT).send().await.context("connecting")?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(anyhow!("Patreon refused the feed: HTTP {}", resp.status()));
|
return Err(anyhow!("Patreon refused the feed: HTTP {}", resp.status()));
|
||||||
}
|
}
|
||||||
@@ -330,7 +430,9 @@ fn plain_text(bytes: &[u8]) -> Option<String> {
|
|||||||
/// Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and Daily Kos.
|
/// Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and Daily Kos.
|
||||||
fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
|
fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
|
||||||
let text = String::from_utf8_lossy(bytes);
|
let text = String::from_utf8_lossy(bytes);
|
||||||
let lower = text.to_lowercase();
|
// ASCII only: to_lowercase changes some characters' length (U+0130 grows a byte), and the
|
||||||
|
// offsets found in the lowered copy then sliced the original off a char boundary.
|
||||||
|
let lower = text.to_ascii_lowercase();
|
||||||
let mut pos = 0;
|
let mut pos = 0;
|
||||||
while let Some(rel) = lower[pos..].find("<link") {
|
while let Some(rel) = lower[pos..].find("<link") {
|
||||||
let start = pos + rel;
|
let start = pos + rel;
|
||||||
@@ -349,6 +451,124 @@ fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What someone asked to add, as a feed: the address itself when it is a feed or an OPML list,
|
||||||
|
/// otherwise the feed its web page links as its own, otherwise an error and nothing is added.
|
||||||
|
/// A page that linked no feed used to be added as it was and failed on every check, called a
|
||||||
|
/// feed that moved (#102); cnn.com is one.
|
||||||
|
pub async fn find_feed(client: &reqwest::Client, url: &str) -> Result<String> {
|
||||||
|
let read = |u: String| async move {
|
||||||
|
let resp = client
|
||||||
|
.get(&u)
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("connecting")?;
|
||||||
|
anyhow::ensure!(resp.status().is_success(), "HTTP {}", resp.status());
|
||||||
|
let base = resp.url().clone();
|
||||||
|
anyhow::Ok((base, resp.bytes().await.context("reading")?))
|
||||||
|
};
|
||||||
|
let is_feed = |b: &[u8]| is_opml(b) || parse(b).is_ok();
|
||||||
|
let (base, body) = read(url.to_owned()).await.with_context(|| format!("could not read {url}"))?;
|
||||||
|
if is_feed(&body) {
|
||||||
|
return Ok(url.to_owned());
|
||||||
|
}
|
||||||
|
if !looks_like_html(&body) {
|
||||||
|
let why = parse(&body).err().map(|e| format!("{e:#}")).unwrap_or_default();
|
||||||
|
anyhow::bail!("{url} is not a feed: {why}");
|
||||||
|
}
|
||||||
|
let Some(href) = alternate_feed_link(&body) else {
|
||||||
|
anyhow::bail!("{url} is a web page that links no feed, so there is nothing to subscribe to");
|
||||||
|
};
|
||||||
|
let linked = base.join(&href).with_context(|| format!("{url} links {href} as its feed, which is not an address"))?;
|
||||||
|
let (_, feed) = read(linked.to_string()).await.with_context(|| format!("{url} links {linked} as its feed, but"))?;
|
||||||
|
anyhow::ensure!(is_feed(&feed), "{url} links {linked} as its feed, but that is not a feed either");
|
||||||
|
Ok(linked.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artwork for a feed that has none: the icon its site's page names, or else the site's
|
||||||
|
/// `/favicon.ico`. None if neither is there.
|
||||||
|
#[tracing::instrument(skip_all, fields(site = site))]
|
||||||
|
pub async fn site_icon(client: &reqwest::Client, site: &str) -> Option<String> {
|
||||||
|
let timeout = std::time::Duration::from_secs(20);
|
||||||
|
let resp = client.get(site).timeout(timeout).send().await.ok()?;
|
||||||
|
// Relative to where the page ended up, not where it was asked for: a site that redirects
|
||||||
|
// to /en/ would otherwise have its icon looked for in the wrong place.
|
||||||
|
let base = resp.url().clone();
|
||||||
|
// The icon a page names can be gone: antirez.com names /images/favicon.png, a 404, while its
|
||||||
|
// /favicon.ico is there. Stored unchecked, it was a broken image that was never looked up again.
|
||||||
|
if resp.status().is_success()
|
||||||
|
&& let Ok(page) = resp.bytes().await
|
||||||
|
&& let Some(href) = page_icon(&page)
|
||||||
|
&& let Ok(url) = base.join(&href)
|
||||||
|
&& is_image(client, url.as_str()).await
|
||||||
|
{
|
||||||
|
return Some(url.into());
|
||||||
|
}
|
||||||
|
let ico = base.join("/favicon.ico").ok()?;
|
||||||
|
is_image(client, ico.as_str()).await.then(|| ico.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artwork's address on https when its host serves it there, else as it was. The page is https
|
||||||
|
/// and must not load http; the host is asked once per `known` (one feed's read), and an http
|
||||||
|
/// address it does not serve on https stays, for /api/art to fetch (#90). Four of the five
|
||||||
|
/// hosts the catalogue had on http served the same image on https; The Secret Cabal's CDN
|
||||||
|
/// presents another name's certificate (#110).
|
||||||
|
pub async fn prefer_https(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
known: &mut std::collections::HashMap<String, bool>,
|
||||||
|
) -> String {
|
||||||
|
let Some(rest) = url.strip_prefix("http://") else { return url.to_owned() };
|
||||||
|
let host = rest.split('/').next().unwrap_or("").to_owned();
|
||||||
|
let secure = format!("https://{rest}");
|
||||||
|
let ok = match known.get(&host) {
|
||||||
|
Some(ok) => *ok,
|
||||||
|
None => {
|
||||||
|
let ok = is_image(client, &secure).await;
|
||||||
|
known.insert(host, ok);
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if ok { secure } else { url.to_owned() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `url` answers with an image. A site with no favicon often answers 200 with its home
|
||||||
|
/// page, which is not an icon.
|
||||||
|
pub async fn is_image(client: &reqwest::Client, url: &str) -> bool {
|
||||||
|
let timeout = std::time::Duration::from_secs(20);
|
||||||
|
let Ok(resp) = client.get(url).timeout(timeout).send().await else { return false };
|
||||||
|
resp.status().is_success()
|
||||||
|
&& resp
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.is_some_and(|t| t.starts_with("image/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The icon a web page names in its `<link>` tags, the larger apple-touch-icon first: a plain
|
||||||
|
/// `icon` is often 16 pixels, which blurs at the size the list draws artwork.
|
||||||
|
fn page_icon(bytes: &[u8]) -> Option<String> {
|
||||||
|
let text = String::from_utf8_lossy(bytes);
|
||||||
|
// ASCII only, so byte offsets in the lowered copy stay valid in the original.
|
||||||
|
let lower = text.to_ascii_lowercase();
|
||||||
|
let (mut touch, mut icon) = (None, None);
|
||||||
|
let mut pos = 0;
|
||||||
|
while let Some(at) = lower[pos..].find("<link") {
|
||||||
|
let start = pos + at;
|
||||||
|
let Some(end) = lower[start..].find('>').map(|e| start + e) else { break };
|
||||||
|
pos = end + 1;
|
||||||
|
let tag = &text[start..end];
|
||||||
|
let Some(rel) = tag_attr(tag, "rel").map(|r| r.to_ascii_lowercase()) else { continue };
|
||||||
|
let rels: Vec<&str> = rel.split_whitespace().collect();
|
||||||
|
if touch.is_none() && rels.iter().any(|r| r.starts_with("apple-touch-icon")) {
|
||||||
|
touch = tag_attr(tag, "href");
|
||||||
|
} else if icon.is_none() && rels.contains(&"icon") {
|
||||||
|
icon = tag_attr(tag, "href");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
touch.or(icon).filter(|h| !h.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
/// The value of one attribute in an HTML/XML start tag, however it is quoted.
|
/// The value of one attribute in an HTML/XML start tag, however it is quoted.
|
||||||
fn tag_attr(tag: &str, name: &str) -> Option<String> {
|
fn tag_attr(tag: &str, name: &str) -> Option<String> {
|
||||||
let key = format!("{name}=");
|
let key = format!("{name}=");
|
||||||
@@ -538,9 +758,11 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
.and_then(|i| i.image())
|
.and_then(|i| i.image())
|
||||||
.map(str::to_owned)
|
.map(str::to_owned)
|
||||||
.or_else(|| ch.image().map(|i| i.url().to_owned())),
|
.or_else(|| ch.image().map(|i| i.url().to_owned())),
|
||||||
|
site: non_empty(Some(ch.link().trim())),
|
||||||
// Only the iTunes one: Apple's list is fixed, while a plain <category> is freeform and
|
// Only the iTunes one: Apple's list is fixed, while a plain <category> is freeform and
|
||||||
// would fill the Directory with one-off tags. The subcategory where there is one: Apple
|
// would fill the Directory with one-off tags. The subcategory where there is one: Apple
|
||||||
// files every tabletop and gaming show under Leisure, which says little; Games says it.
|
// files every tabletop and gaming show under Leisure, which says little; Games says it.
|
||||||
|
description: non_empty(Some(ch.description())).or_else(|| non_empty(ch.itunes_ext().and_then(|i| i.summary()))),
|
||||||
category: ch
|
category: ch
|
||||||
.itunes_ext()
|
.itunes_ext()
|
||||||
.and_then(|i| i.categories().first())
|
.and_then(|i| i.categories().first())
|
||||||
@@ -603,7 +825,14 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
|||||||
title: title_text(Some(feed.title().as_str())),
|
title: title_text(Some(feed.title().as_str())),
|
||||||
ttl_mins: None,
|
ttl_mins: None,
|
||||||
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
|
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
|
||||||
|
site: feed
|
||||||
|
.links()
|
||||||
|
.iter()
|
||||||
|
.find(|l| l.rel() == "alternate")
|
||||||
|
.map(|l| l.href().trim().to_owned())
|
||||||
|
.filter(|h| !h.is_empty()),
|
||||||
category: None,
|
category: None,
|
||||||
|
description: non_empty(feed.subtitle().map(|t| t.as_str())),
|
||||||
entries,
|
entries,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -809,18 +1038,206 @@ fn parse_date(s: &str) -> Option<i64> {
|
|||||||
.map(|d| d.timestamp())
|
.map(|d| d.timestamp())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apple's podcast categories, each with its subcategories
|
||||||
|
/// (https://podcasters.apple.com/support/1691-apple-podcasts-categories): the list a podcast's own
|
||||||
|
/// `<itunes:category>` comes from, and a fixed one, so the Directory's categories stay few.
|
||||||
|
const CATEGORIES: &[(&str, &[&str])] = &[
|
||||||
|
("Arts", &["Books", "Design", "Fashion & Beauty", "Food", "Performing Arts", "Visual Arts"]),
|
||||||
|
("Business", &["Careers", "Entrepreneurship", "Investing", "Management", "Marketing", "Non-Profit"]),
|
||||||
|
("Comedy", &["Comedy Interviews", "Improv", "Stand-Up"]),
|
||||||
|
("Education", &["Courses", "How To", "Language Learning", "Self-Improvement"]),
|
||||||
|
("Fiction", &["Comedy Fiction", "Drama", "Science Fiction"]),
|
||||||
|
("Government", &[]),
|
||||||
|
("History", &[]),
|
||||||
|
("Health & Fitness", &["Alternative Health", "Fitness", "Medicine", "Mental Health", "Nutrition", "Sexuality"]),
|
||||||
|
("Kids & Family", &["Education for Kids", "Parenting", "Pets & Animals", "Stories for Kids"]),
|
||||||
|
("Leisure", &["Animation & Manga", "Automotive", "Aviation", "Crafts", "Games", "Hobbies", "Home & Garden", "Video Games"]),
|
||||||
|
("Music", &["Music Commentary", "Music History", "Music Interviews"]),
|
||||||
|
("News", &["Business News", "Daily News", "Entertainment News", "News Commentary", "Politics", "Sports News", "Tech News"]),
|
||||||
|
("Religion & Spirituality", &["Buddhism", "Christianity", "Hinduism", "Islam", "Judaism", "Religion", "Spirituality"]),
|
||||||
|
("Science", &["Astronomy", "Chemistry", "Earth Sciences", "Life Sciences", "Mathematics", "Natural Sciences", "Nature", "Physics", "Social Sciences"]),
|
||||||
|
("Society & Culture", &["Documentary", "Personal Journals", "Philosophy", "Places & Travel", "Relationships"]),
|
||||||
|
("Sports", &["Baseball", "Basketball", "Cricket", "Fantasy Sports", "Football", "Golf", "Hockey", "Rugby", "Running", "Soccer", "Swimming", "Tennis", "Volleyball", "Wilderness", "Wrestling"]),
|
||||||
|
("Technology", &[]),
|
||||||
|
("True Crime", &[]),
|
||||||
|
("TV & Film", &["After Shows", "Film History", "Film Interviews", "Film Reviews", "TV Reviews"]),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Names that are not in Apple's list but mean one that is (#125). Jev picked from this list when
|
||||||
|
/// it spelt Football and Soccer its own way, and stored those answers; Software How-To is Apple's
|
||||||
|
/// Technology subcategory from before its 2019 list, which older feeds still name.
|
||||||
|
const ALIASES: &[(&str, &str)] =
|
||||||
|
&[("American Football", "Football"), ("Football (Soccer)", "Soccer"), ("Software How-To", "Technology")];
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
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();
|
||||||
|
let name = ALIASES.iter().find(|(old, _)| old.eq_ignore_ascii_case(name)).map_or(name, |(_, now)| now);
|
||||||
|
for (category, subs) in CATEGORIES {
|
||||||
|
if category.eq_ignore_ascii_case(name) {
|
||||||
|
return (category.to_string(), None);
|
||||||
|
}
|
||||||
|
if let Some(sub) = subs.iter().find(|s| s.eq_ignore_ascii_case(name)) {
|
||||||
|
return (category.to_string(), Some(sub.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(name.to_string(), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// is taken however unsure, since an admin can change it and asking again would cost a call a scan.
|
||||||
|
pub async fn classify(client: &reqwest::Client, key: &str, feed: &ParsedFeed) -> Result<String> {
|
||||||
|
let items: Vec<&str> = feed.entries.iter().filter_map(|e| e.title.as_deref()).take(15).collect();
|
||||||
|
// A subcategory says which category it is in; without that, "Games" or "Drama" is ambiguous.
|
||||||
|
let mut criteria = serde_json::Map::new();
|
||||||
|
for (category, subs) in CATEGORIES {
|
||||||
|
criteria.insert(category.to_string(), serde_json::Value::Null);
|
||||||
|
for sub in *subs {
|
||||||
|
criteria.insert(sub.to_string(), format!("Within {category}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"model": "jev-latest",
|
||||||
|
"state": { "title": feed.title, "recent_items": items },
|
||||||
|
"questions": { "category": {
|
||||||
|
"type": "choice",
|
||||||
|
"instructions": "Which category best describes what this feed publishes?",
|
||||||
|
"criteria": criteria,
|
||||||
|
}},
|
||||||
|
});
|
||||||
|
let answer: serde_json::Value = client
|
||||||
|
.post("https://api.typesafe.ai/v1/systemone")
|
||||||
|
.bearer_auth(key)
|
||||||
|
.json(&body)
|
||||||
|
.timeout(FEED_TIMEOUT)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
picked(&answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn picked(answer: &serde_json::Value) -> Result<String> {
|
||||||
|
let choice = answer["answers"]["category"]["choice"].as_str().unwrap_or_default();
|
||||||
|
CATEGORIES
|
||||||
|
.iter()
|
||||||
|
.flat_map(|(c, subs)| std::iter::once(c).chain(subs.iter()))
|
||||||
|
.find(|c| **c == choice)
|
||||||
|
.map(|c| c.to_string())
|
||||||
|
.ok_or_else(|| anyhow!("no category in the answer: {answer}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An address that carries a subscriber's own access: a paid-feed host, credentials in the URL,
|
||||||
|
/// or a key in the query. ipx leaves such a feed out of anything others see, and the directory
|
||||||
|
/// refuses one sent to it (#202).
|
||||||
|
///
|
||||||
|
/// ponytail: a heuristic. A key hidden in the path of a host not in `PAID_HOSTS` gets
|
||||||
|
/// through; a per-feed private flag is the upgrade (#205).
|
||||||
|
pub fn url_looks_private(feed_url: &str) -> bool {
|
||||||
|
let Ok(u) = url::Url::parse(feed_url) else { return true };
|
||||||
|
// Paid-feed services put the subscriber's key in the path as often as in the query, and a
|
||||||
|
// long path segment alone proves nothing (acast's public show ids look the same). So a
|
||||||
|
// feed from one of them is private whatever its URL looks like. Supercast is why this
|
||||||
|
// exists: `feeds.supercast.com/feeds/<key>` reached the directory before it did.
|
||||||
|
let host = u.host_str().unwrap_or("");
|
||||||
|
PAID_HOSTS.iter().any(|h| host == *h || host.ends_with(&format!(".{h}")))
|
||||||
|
|| !u.username().is_empty()
|
||||||
|
|| u.password().is_some()
|
||||||
|
|| u.query_pairs().any(|(k, _)| {
|
||||||
|
let k = k.to_ascii_lowercase();
|
||||||
|
["auth", "token", "key", "secret", "pass", "sig", "session", "user", "uid"]
|
||||||
|
.iter()
|
||||||
|
.any(|w| k.contains(w))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Services whose feeds are always one subscriber's own.
|
||||||
|
const PAID_HOSTS: &[&str] =
|
||||||
|
&["patreon.com", "supercast.com", "supportingcast.fm", "glow.fm", "memberful.com"];
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn an_address_carrying_someones_access_looks_private() {
|
||||||
|
let private = super::url_looks_private;
|
||||||
|
assert!(!private("https://feeds.twit.tv/twit.xml"));
|
||||||
|
// Patreon's shape: the key is a query parameter.
|
||||||
|
assert!(private("https://www.patreon.com/rss/x?auth=abc123&show=2073588"));
|
||||||
|
assert!(private("https://example.com/rss?api_key=abc"));
|
||||||
|
// Paid-feed services put the key in the path; the host gives them away.
|
||||||
|
assert!(private("https://feeds.supercast.com/feeds/abcdefghijklmnopqrstuvwx"));
|
||||||
|
assert!(private("https://someshow.supportingcast.fm/content/abc123.rss"));
|
||||||
|
// A long path segment alone is not a key: acast's public show ids look the same.
|
||||||
|
assert!(!private("https://feeds.acast.com/public/shows/0123456789abcdef01234567"));
|
||||||
|
assert!(private("https://ray:hunter2@example.com/rss"));
|
||||||
|
assert!(private("not a url"), "unparseable is not safe to list");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A server answering by path: /old moves for good to /new, /tmp for now, /chain for good
|
||||||
|
/// to /tmp, /new is the feed.
|
||||||
|
async fn redirecting_server() -> String {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut buf = [0u8; 2048];
|
||||||
|
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||||
|
let req = String::from_utf8_lossy(&buf[..n]);
|
||||||
|
let path = req.split_whitespace().nth(1).unwrap_or("/").to_owned();
|
||||||
|
let body = "<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title></channel></rss>";
|
||||||
|
let resp = match path.as_str() {
|
||||||
|
"/old" => "HTTP/1.1 301 Moved Permanently\r\nLocation: /new\r\nContent-Length: 0\r\n\r\n".to_owned(),
|
||||||
|
"/tmp" => "HTTP/1.1 302 Found\r\nLocation: /new\r\nContent-Length: 0\r\n\r\n".to_owned(),
|
||||||
|
"/chain" => "HTTP/1.1 308 Permanent Redirect\r\nLocation: /tmp\r\nContent-Length: 0\r\n\r\n".to_owned(),
|
||||||
|
"/loop" => "HTTP/1.1 301 Moved Permanently\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n".to_owned(),
|
||||||
|
_ => format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len()),
|
||||||
|
};
|
||||||
|
let _ = sock.write_all(resp.as_bytes()).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
format!("http://{addr}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_feed_that_moved_for_good_says_where_and_one_moved_for_now_does_not() {
|
||||||
|
let base = redirecting_server().await;
|
||||||
|
let client = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build().unwrap();
|
||||||
|
let get = |path: &str| {
|
||||||
|
let url = format!("{base}{path}");
|
||||||
|
let client = client.clone();
|
||||||
|
async move { super::fetch(&client, &url, None, None, None).await }
|
||||||
|
};
|
||||||
|
let (got, moved) = get("/old").await.unwrap();
|
||||||
|
assert!(matches!(got, super::Fetched::Body { .. }));
|
||||||
|
assert_eq!(moved, Some(format!("{base}/new")));
|
||||||
|
assert_eq!(get("/tmp").await.unwrap().1, None); // 302: for now
|
||||||
|
assert_eq!(get("/chain").await.unwrap().1, None); // 308 then 302: not for good
|
||||||
|
assert_eq!(get("/new").await.unwrap().1, None); // never moved
|
||||||
|
let looped = get("/loop").await.err().unwrap().to_string();
|
||||||
|
assert_eq!(super::failure_kind(&looped).0, "redirect_loop");
|
||||||
|
}
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_rss_with_itunes_extensions() {
|
fn parses_rss_with_itunes_extensions() {
|
||||||
let bytes = include_bytes!("../tests/data/rss2.xml");
|
let bytes = include_bytes!("../../../tests/data/rss2.xml");
|
||||||
let feed = parse(bytes).unwrap();
|
let feed = parse(bytes).unwrap();
|
||||||
|
|
||||||
assert_eq!(feed.title.as_deref(), Some("Test Cast"));
|
assert_eq!(feed.title.as_deref(), Some("Test Cast"));
|
||||||
assert_eq!(feed.ttl_mins, Some(45));
|
assert_eq!(feed.ttl_mins, Some(45));
|
||||||
assert_eq!(feed.category.as_deref(), Some("Podcasting"), "the first, by its subcategory");
|
assert_eq!(feed.category.as_deref(), Some("Podcasting"), "the first, by its subcategory");
|
||||||
|
assert_eq!(feed.description.as_deref(), Some("A synthetic feed used by the parser tests."));
|
||||||
assert_eq!(feed.entries.len(), 3);
|
assert_eq!(feed.entries.len(), 3);
|
||||||
|
|
||||||
let ep = &feed.entries[0];
|
let ep = &feed.entries[0];
|
||||||
@@ -845,6 +1262,17 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_feed_says_what_it_is_in_itunes_summary_or_atom_subtitle() {
|
||||||
|
// An empty <description> gives way to iTunes' summary (#130).
|
||||||
|
let rss = br#"<?xml version="1.0"?><rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||||
|
<channel><title>R</title><description> </description><itunes:summary>Said here.</itunes:summary></channel></rss>"#;
|
||||||
|
assert_eq!(parse(rss).unwrap().description.as_deref(), Some("Said here."));
|
||||||
|
let atom = br#"<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><title>A</title>
|
||||||
|
<subtitle>Or here.</subtitle><id>urn:a</id><updated>2026-01-01T00:00:00Z</updated></feed>"#;
|
||||||
|
assert_eq!(parse(atom).unwrap().description.as_deref(), Some("Or here."));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_file_wordpress_lists_twice_is_one_enclosure() {
|
fn a_file_wordpress_lists_twice_is_one_enclosure() {
|
||||||
let xml = br#"<?xml version="1.0"?><rss version="2.0"><channel><title>R</title>
|
let xml = br#"<?xml version="1.0"?><rss version="2.0"><channel><title>R</title>
|
||||||
@@ -928,6 +1356,20 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failures_are_named_by_kind_for_the_log() {
|
||||||
|
let k = |m: &str| failure_kind(m);
|
||||||
|
assert_eq!(k("HTTP 404 Not Found"), ("404".into(), Some(404)));
|
||||||
|
assert_eq!(k("HTTP 503 Service Unavailable"), ("503".into(), Some(503)));
|
||||||
|
assert_eq!(
|
||||||
|
k("connecting: error following redirect for url (https://www.toddstashwick.com/): too many redirects").0,
|
||||||
|
"redirect_loop"
|
||||||
|
);
|
||||||
|
assert_eq!(k("connecting: dns error: failed to lookup address information").0, "dns");
|
||||||
|
assert_eq!(k("operation timed out").0, "timeout");
|
||||||
|
assert_eq!(k("something new").0, "other");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explain_failure_translates_the_errors_the_ui_should_flag() {
|
fn explain_failure_translates_the_errors_the_ui_should_flag() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -937,8 +1379,17 @@ mod tests {
|
|||||||
assert_eq!(explain_failure("HTTP 401 Unauthorized").unwrap().reason, "The site refuses ipx's requests.");
|
assert_eq!(explain_failure("HTTP 401 Unauthorized").unwrap().reason, "The site refuses ipx's requests.");
|
||||||
assert_eq!(explain_failure("HTTP 403 Forbidden").unwrap().reason, "The site refuses ipx's requests.");
|
assert_eq!(explain_failure("HTTP 403 Forbidden").unwrap().reason, "The site refuses ipx's requests.");
|
||||||
assert_eq!(explain_failure("HTTP 402 Payment Required").unwrap().reason, "The feed now needs a paid plan.");
|
assert_eq!(explain_failure("HTTP 402 Payment Required").unwrap().reason, "The feed now needs a paid plan.");
|
||||||
let dns = explain_failure("connecting: dns error: failed to lookup address information").unwrap();
|
assert_eq!(
|
||||||
assert_eq!(dns.reason, "This address no longer resolves; the site is gone.");
|
explain_failure("the feed's address answered with nothing at all").unwrap().reason,
|
||||||
|
"This address answers with nothing; the site may be gone."
|
||||||
|
);
|
||||||
|
let dns = explain_failure("connecting: dns error: failed to lookup address information: Name or service not known").unwrap();
|
||||||
|
assert_eq!(dns.reason, "This address no longer resolves; the site may be gone.");
|
||||||
|
// A lookup that failed for the moment is not a site gone (#143).
|
||||||
|
let blip = "connecting: dns error: failed to lookup address information: Temporary failure in name resolution";
|
||||||
|
assert!(is_temporary_lookup_failure(blip));
|
||||||
|
assert!(explain_failure(blip).is_none());
|
||||||
|
assert!(!is_temporary_lookup_failure("connecting: dns error: failed to lookup address information: Name or service not known"));
|
||||||
let moved = explain_failure("got a web page, not a feed; it links https://x/feed as its feed").unwrap();
|
let moved = explain_failure("got a web page, not a feed; it links https://x/feed as its feed").unwrap();
|
||||||
assert_eq!(moved.new_url.as_deref(), Some("https://x/feed"));
|
assert_eq!(moved.new_url.as_deref(), Some("https://x/feed"));
|
||||||
assert!(explain_failure("got a web page, not a feed").unwrap().new_url.is_none());
|
assert!(explain_failure("got a web page, not a feed").unwrap().new_url.is_none());
|
||||||
@@ -986,7 +1437,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_atom_enclosure_links() {
|
fn parses_atom_enclosure_links() {
|
||||||
let bytes = include_bytes!("../tests/data/atom.xml");
|
let bytes = include_bytes!("../../../tests/data/atom.xml");
|
||||||
let feed = parse(bytes).unwrap();
|
let feed = parse(bytes).unwrap();
|
||||||
|
|
||||||
assert_eq!(feed.title.as_deref(), Some("Atom Cast"));
|
assert_eq!(feed.title.as_deref(), Some("Atom Cast"));
|
||||||
@@ -1084,8 +1535,8 @@ mod tests {
|
|||||||
assert_eq!(feeds[0], ("Alpha".into(), "https://a.example/rss".into()));
|
assert_eq!(feeds[0], ("Alpha".into(), "https://a.example/rss".into()));
|
||||||
|
|
||||||
// A feed must never be mistaken for a subscription list.
|
// A feed must never be mistaken for a subscription list.
|
||||||
assert!(!is_opml(include_bytes!("../tests/data/rss2.xml")));
|
assert!(!is_opml(include_bytes!("../../../tests/data/rss2.xml")));
|
||||||
assert!(!is_opml(include_bytes!("../tests/data/atom.xml")));
|
assert!(!is_opml(include_bytes!("../../../tests/data/atom.xml")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1093,6 +1544,10 @@ mod tests {
|
|||||||
let tok = "AbCdEfGhIjKlMnOpQrStUvWxYz012_-9";
|
let tok = "AbCdEfGhIjKlMnOpQrStUvWxYz012_-9";
|
||||||
assert_eq!(expand_input(&format!(" {tok} ")), format!("https://www.patreon.com/rss?auth={tok}"));
|
assert_eq!(expand_input(&format!(" {tok} ")), format!("https://www.patreon.com/rss?auth={tok}"));
|
||||||
assert_eq!(expand_input("https://example.com/rss"), "https://example.com/rss");
|
assert_eq!(expand_input("https://example.com/rss"), "https://example.com/rss");
|
||||||
|
assert_eq!(expand_input("http://example.com/rss"), "http://example.com/rss");
|
||||||
|
assert_eq!(expand_input(" cnn.com "), "https://cnn.com");
|
||||||
|
assert_eq!(expand_input("example.com/feed.xml"), "https://example.com/feed.xml");
|
||||||
|
assert_eq!(expand_input("//example.com/rss"), "https://example.com/rss");
|
||||||
|
|
||||||
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss/glasscannon?auth={tok}")));
|
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss/glasscannon?auth={tok}")));
|
||||||
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss?auth={tok}")));
|
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss?auth={tok}")));
|
||||||
@@ -1157,6 +1612,24 @@ mod tests {
|
|||||||
assert_eq!(f.entries[2].enclosures.len(), 0, "an item may have none");
|
assert_eq!(f.entries[2].enclosures.len(), 0, "an item may have none");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_feed_link_after_non_ascii_text_is_found() {
|
||||||
|
// U+0130 lowercases to three bytes from two, which once shifted every offset after it.
|
||||||
|
let page = "<html><title>\u{130}stanbul \u{130}\u{130}</title>\
|
||||||
|
<link rel=\"alternate\" type=\"application/rss+xml\" href=\"/feed.xml\">";
|
||||||
|
assert_eq!(alternate_feed_link(page.as_bytes()).as_deref(), Some("/feed.xml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_icon_prefers_the_touch_icon() {
|
||||||
|
let page = br#"<head><link rel="stylesheet" href="/a.css">
|
||||||
|
<link rel="shortcut icon" href="/fav.ico">
|
||||||
|
<LINK REL="apple-touch-icon-precomposed" sizes="180x180" href='/touch.png'></head>"#;
|
||||||
|
assert_eq!(page_icon(page).as_deref(), Some("/touch.png"));
|
||||||
|
assert_eq!(page_icon(br#"<link rel="icon" href="i.svg">"#).as_deref(), Some("i.svg"));
|
||||||
|
assert_eq!(page_icon(br#"<link rel="stylesheet" href="/a.css">"#), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_items_picture_comes_from_the_most_deliberate_source() {
|
fn an_items_picture_comes_from_the_most_deliberate_source() {
|
||||||
let xml = br#"<?xml version="1.0"?>
|
let xml = br#"<?xml version="1.0"?>
|
||||||
@@ -1208,5 +1681,29 @@ mod tests {
|
|||||||
assert_eq!(pick_guid(None, None, Some("e"), Some("t")).as_deref(), Some("e"));
|
assert_eq!(pick_guid(None, None, Some("e"), Some("t")).as_deref(), Some("e"));
|
||||||
assert_eq!(pick_guid(None, None, None, None), None);
|
assert_eq!(pick_guid(None, None, None, None), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picked_takes_only_an_apple_category() {
|
||||||
|
let answer = |c: &str| serde_json::json!({"answers": {"category": {"type": "choice", "choice": c}}});
|
||||||
|
assert_eq!(super::picked(&answer("Technology")).unwrap(), "Technology");
|
||||||
|
assert_eq!(super::picked(&answer("Tech News")).unwrap(), "Tech News", "a subcategory");
|
||||||
|
assert!(super::picked(&answer("Blogs")).is_err(), "not one of Apple's");
|
||||||
|
assert!(super::picked(&serde_json::json!({"error": "rate limited"})).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn category_path_finds_a_subcategory_s_category() {
|
||||||
|
use super::category_path;
|
||||||
|
assert_eq!(category_path("Tech News"), ("News".into(), Some("Tech News".into())));
|
||||||
|
assert_eq!(category_path("video games"), ("Leisure".into(), Some("Video Games".into())), "Apple's spelling");
|
||||||
|
assert_eq!(category_path("Technology"), ("Technology".into(), None));
|
||||||
|
assert_eq!(category_path("Homebrew"), ("Homebrew".into(), None), "not Apple's: its own category");
|
||||||
|
// Apple's spelling, an old answer of Jev's, a stray comma and Apple's old list (#125).
|
||||||
|
assert_eq!(category_path("Soccer"), ("Sports".into(), Some("Soccer".into())));
|
||||||
|
assert_eq!(category_path("Football (Soccer)"), ("Sports".into(), Some("Soccer".into())));
|
||||||
|
assert_eq!(category_path("Soccer,"), ("Sports".into(), Some("Soccer".into())));
|
||||||
|
assert_eq!(category_path("Football"), ("Sports".into(), Some("Football".into())));
|
||||||
|
assert_eq!(category_path("Software How-To"), ("Technology".into(), None));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,12 +1,237 @@
|
|||||||
# Changelog
|
# Changelog: ipx
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
The daemon: the API, scans, downloads, DeadAir and the database. The page has its own changelog,
|
||||||
|
[web/CHANGELOG.md](../../web/CHANGELOG.md), and the directory service its own,
|
||||||
|
[crates/directory/CHANGELOG.md](../directory/CHANGELOG.md). Until 0.12.0 this was the changelog of
|
||||||
|
all of ipx, the page included; they were split with the services (#207).
|
||||||
|
|
||||||
|
Its version is in `crates/ipx/Cargo.toml`, and a release is tagged `vX.Y.Z`.
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-10-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- With a directory token that may administer it, ipx's admins remove a feed from the directory (blocking it) or check it now, list and unblock what is blocked, and list and drop the servers sharing (#206).
|
||||||
|
- ipx passes its trace on when it calls the directory, so a Directory page is one trace from the browser to the directory (#213).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The Directory comes from a directory service that iPX servers share, set up in Admin, Directory with its address and this server's token, instead of only this server's feeds. Sharing this server's public feeds with it is a setting, never a private one, and subscriber counts are every sharing server's. ipx sends the directory its feeds when they change, and every half hour (#205, #211).
|
||||||
|
- A feed can be marked private, which keeps it out of the directory. A feed with a login, or whose address carries a key, is private already (#205).
|
||||||
|
- ipx serves no page: the page is ipx-web's, which passes the API on to ipx, and ipx is built without node (#203).
|
||||||
|
- `docker-compose.yml` is a sample to start from, ipx alone on SQLite with its folders beside the file, instead of a copy of the author's server (#208).
|
||||||
|
- The source is a Cargo workspace: the daemon in `crates/ipx`, feed fetching and parsing in `crates/feed`. `cargo build --release` still makes `target/release/ipx` (#202).
|
||||||
|
|
||||||
|
## [0.12.0] - 2026-10-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- With `[web] metrics_bind` set, ipx serves Prometheus metrics on a port and thread of their own: the database pool, the worker's job and queue, jobs done and their time, scans, downloads and requests in progress, download speed, live-update listeners and missed updates, DeadAir's backlog, the artwork cache's fill, hit rate and evictions, memory, open files and the library's counts. (#194, #195, #198, #199).
|
||||||
|
- The page reports its errors, the player's, how long play takes to start and how long it stalls to the server's log, and the requests one tap sets off share a trace, the playlist, audio and saved place of what it played included (#190, #192).
|
||||||
|
- Saving an episode from a show with DeadAir on saves it without its dead air, at your breathing room (#186).
|
||||||
|
- Choose the pause DeadAir leaves where it cuts a silence, from 0.2 to 0.5 seconds, for each show in its settings, as its breathing room, on a slider from brisk to relaxed. It is 0.3 seconds unless you change it, up from 0.2, which made speech sound rushed (#182, #183).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- DeadAir no longer makes a shortened copy of each episode. It plays the original with its long silences left out, ready a few seconds after the download, with no gap where a silence was cut and no extra disk space; the copies already made are removed (#179, #181, #196).
|
||||||
|
- DeadAir is in the same box as a show's download settings, below them, and stays there when automatic downloads are off (#185).
|
||||||
|
- The container is built on Debian 13 (trixie) instead of 12, so DeadAir uses ffmpeg 7.1 instead of 5.1 (#177).
|
||||||
|
- On Postgres, ipx keeps up to 20 connections to the database instead of 10, for the bursts of a page load asking for many covers at once (#200).
|
||||||
|
- Traces of the disk sweep, episode lists and DeadAir's work on each file show each step, and the sweep's trace says what it removed (#187).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- All Subscriptions, and every item list, comes up faster, most of all the first time after a quiet spell: it took half a second to two (#188).
|
||||||
|
- Checking for new episodes no longer waits up to 12 seconds each time on a clear-out of dead feeds from the Directory, which now runs once a day (#193).
|
||||||
|
- A page left open no longer misses updates after a check for new episodes: the note that each feed not due was skipped, one per feed, filled the queue they share (#197).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Cloudflare no longer keeps a copy of the audio you play, which it served to anyone it let in without checking they subscribe (#189).
|
||||||
|
|
||||||
|
## [0.11.0] - 2026-10-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- DeadAir: turn it on for a show in its settings, or with the button beside the speed while it plays, and its episodes play with long silences shortened, so podcasts finish sooner without sounding sped up. Your place carries over when you turn it on or off, each episode says how much shorter it is ("Cutting dead air…" until it is done), and Settings shows the time it has saved you. It is off for every show until you turn it on (#171, #172, #173, #174, #176).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A downloaded file's play, save, share and delete buttons stay on one row in the Files pane, instead of delete wrapping underneath (#175).
|
||||||
|
- Cover art up to 20 MB is shown, instead of nothing for anything over 5 MB, as for five of the Glass Cannon Network's feeds.
|
||||||
|
- Seeking in an episode while many people listen answers in about 10 ms instead of 40, and files stream about four times as fast: each connection now sends what is ready without waiting for the browser to acknowledge the last part.
|
||||||
|
- Artwork that could not be fetched is not fetched again for an hour, instead of on every page load, and the server's log says which image it was and why.
|
||||||
|
|
||||||
|
## [0.10.3] - 2026-10-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Swiping past the last item shows the theme's accent colour, without the words "No more items".
|
||||||
|
- An empty list and the reader with nothing picked say nothing, instead of "Nothing here yet — try Scan now." and "Pick an item to read it."; a search that finds nothing still says so.
|
||||||
|
- In Settings, the API tokens sit in a box of their own.
|
||||||
|
- A feed's settings are redesigned: downloading on its own is a switch with what depends on it beneath, how many is Default, Newest or All shown on a strip of the show's own episodes, words to pick or hide are chips, the feed's address is at the top with a copy button, and Save is always in reach. Its folder and Directory category are no longer in it.
|
||||||
|
- The feed list's traces show each of its database queries, so it can be told where its time goes.
|
||||||
|
- Saving a feed's settings asks the database for your subscription once, not twice.
|
||||||
|
- `ipx add` and Add feed in the page add a feed the same way, through one piece of code.
|
||||||
|
- A pinned item or feed's pin button is filled with the theme's accent, in the reader, on the feed's page and in the toolbar, so pinned no longer looks like not pinned.
|
||||||
|
- A feed's page in the Directory says how many people here subscribe only when someone does, without "Nobody here subscribes yet".
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The unread counts catch up when you come back to the page: an item read on another device, or while the phone app was in the background, no longer stays counted as unread until a reload.
|
||||||
|
- A feed set to download 0 of its newest items downloads all of them, the whole back catalogue, as the server's 0 does; it downloaded nothing.
|
||||||
|
- The feed list loads faster: it counts the items and files of your own feeds, not of every feed on the server, and asks for a group's title once, not once for every feed in it.
|
||||||
|
- Downloading or deleting a file the server no longer has answers "not found" instead of a server error.
|
||||||
|
- A feed's page in the Directory, and subscribing from it, no longer wait while the whole Directory is put together to check that the one feed is in it.
|
||||||
|
- An episode that came as a torrent of several files plays its largest file, and can be deleted, by hand or to free space, instead of staying on disk for good.
|
||||||
|
- Removing an account takes the feeds only that person read off the server, as unsubscribing does, instead of leaving them downloading every new episode for no one.
|
||||||
|
- Freeing disk space counts only the people who subscribe to a feed: an episode is no longer taken as heard by everyone because someone who has since unsubscribed heard it, and their pins no longer keep files for good.
|
||||||
|
- An episode whose file turns out to be a torrent shows as downloaded once it is, instead of being fetched again on every check of its feed, and no longer holds up the checking of other feeds while it downloads and seeds.
|
||||||
|
- Downloading the latest 65 to 100 items of a feed queues them at once, instead of hanging until most had downloaded and holding up adding feeds and Refresh meanwhile.
|
||||||
|
- Changes to the feed list made at the same moment, such as two people adding feeds, or a feed added while the server settings change, no longer undo one another.
|
||||||
|
- A download that stops partway is given up after two minutes without data, instead of holding up every scan and download until a restart.
|
||||||
|
- On a phone, the closed feed list no longer shades the left edge of the page with its shadow.
|
||||||
|
- On an iPhone, the top bar's colour runs all the way up behind the status bar and the Dynamic Island, with no band of another colour above it. In Glass the bar is solid on a phone, in the colour its glass showed.
|
||||||
|
- A feed's buttons stay on one row on a narrow phone, and the reader's buttons stay together instead of breaking across lines under a long feed name.
|
||||||
|
- A moment's DNS failure no longer marks a feed as failing for a day or calls it gone: iPX tries it again in ten minutes. One failed lookup one morning left 205 podcasts unread, uncategorised and labelled gone until the next day. A name that does not resolve is said to be maybe gone, and only once it has been failing a day, on its Directory page as on its own.
|
||||||
|
|
||||||
|
## [0.10.2] - 2026-10-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A publisher's correction to an item already in iPX, a retitled episode, mended text, new artwork or a fixed length, arrives on the next read. Since 0.9.1 an item kept what it first said: Ain't It Cool News's items each showed the text of the one before, long after the feed was fixed.
|
||||||
|
- A feed's page in the Directory says when the feed is failing, and why, and no longer says nobody here subscribes to a feed someone does.
|
||||||
|
|
||||||
|
## [0.10.1] - 2026-10-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A feed whose address answers with nothing, as a lapsed domain does behind a DNS filter's block page, shows as failing, with what to do about it, instead of as a show that has not posted yet.
|
||||||
|
- The search box finds episodes in Currently Listening. It did nothing there.
|
||||||
|
- `ipx add`, `ipx rm` and `ipx import`, run while the daemon runs, reach it at once. It kept its old list of feeds and wrote it back at its next change, undoing them.
|
||||||
|
- On SQLite, several requests are answered at once instead of one at a time. With 25 people browsing, a page of a feed's items comes back in 10ms rather than 130.
|
||||||
|
- On Postgres, a write no longer waits for the database server's disk. A scan read about one feed a second and now reads 1,500 in 20 seconds.
|
||||||
|
- Checking every feed no longer makes every icon in the feed list flash while it runs: the list keeps the icons it has already drawn.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- A flood of sign-in attempts no longer stalls the site for everyone else. Passwords are checked a few at a time, away from the threads that answer every other request; forty wrong passwords at a time made everything else take four seconds.
|
||||||
|
- A wrong password is refused in the same time whether or not the name is an account here, so how long it takes no longer tells anyone which names are.
|
||||||
|
|
||||||
|
## [0.10.0] - 2026-10-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- API tokens: in Settings, make a named token that lets a script or an agent use iPX as you, sent as `Authorization: Bearer`, and revoke it there. Each shows when it was last used.
|
||||||
|
- In the Directory, a feed you do not subscribe to opens a page of its own: its cover, its category, how many people here subscribe, what the feed says it is, and its latest episodes or posts, with a button to subscribe. Before, only its subscribe button did anything.
|
||||||
|
- Feeds that don't set a category, as most blogs don't, now get one automatically, so they show up under a category in the Directory. iPX picks the closest of Apple's podcast categories from the feed's title and latest headlines. This happens once per feed: the first time a new feed is read, or for a feed you already had, the next time you force a refresh. It needs `TYPESAFE_KEY` set. An admin can change the category in the feed's settings.
|
||||||
|
- Artwork is kept on disk once shown and loads from iPX, not from each publisher's server: fast after the first time, and still there when the publisher's server is not. The admin page sets how much is kept (500 MB by default).
|
||||||
|
- `ipx add --list --category News <url>` puts a feed in the Directory for anyone to subscribe to,
|
||||||
|
and it stays there when its last subscriber leaves. Run for a feed already in the catalogue,
|
||||||
|
it lists it or sets its category.
|
||||||
|
- Feeds nobody subscribes to that are dead for a month or quiet for a year are removed, so the
|
||||||
|
Directory lists feeds worth taking.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The Directory is laid out as Apple's is. It opens on the ten most subscribed feeds, ranked, and the categories as tiles showing some of their shows' covers. A category opens a page of its own with its most subscribed feeds, its subcategories (Tech News under News, Video Games under Leisure) and its feeds; See all lists every one, A to Z, Z to A or by most subscribers. This replaces the grid of every feed under rows of category buttons, and Popular, which is gone from the feed list with its key, g p.
|
||||||
|
- A pinned item in a list wears the same disc as a pinned feed, in the theme's accent, instead of a plain solid pin.
|
||||||
|
- An item published without a title shows its opening words, in plain text rather than bold, instead of "(untitled)"; one with no text either shows its file's name, or its show and date. Opened, it starts with its text.
|
||||||
|
- A feed that has moved for good (a permanent redirect) is followed to its new address, which iPX then reads from, and says so in the log as `feed_moved`. A temporary redirect changes nothing.
|
||||||
|
- The daemon sleeps until the next feed is due, at most ten minutes, instead of looking every minute; refreshing or adding a feed still wakes it at once.
|
||||||
|
- A pinned feed's pin sits on the corner of its artwork, as a failing feed's mark does, instead of before its name.
|
||||||
|
- The Directory lists feeds nobody subscribes to yet; its most subscribed still lists what people subscribe to.
|
||||||
|
A feed nobody subscribes to is checked once a day, and at once when someone subscribes. A
|
||||||
|
listed feed downloads nothing until someone subscribes.
|
||||||
|
- The Directory loads faster: it asked the database three questions per feed.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A database on SQLite, the default, no longer waits for the disk after nearly every write. On a slow disk a scan of 1,500 feeds took most of an hour and now takes half a minute. Postgres is unaffected.
|
||||||
|
- The search box finds a feed in the Directory by name. It did nothing there.
|
||||||
|
- Feeds that file themselves under Apple's Football or Soccer show under Sports, and those under the old Software How-To under Technology, instead of as categories of their own.
|
||||||
|
- The download queue, in `ipx status`, `/api/status` and the dashboard, counts only what iPX will download on its own: older episodes beyond a show's limit, and files of feeds that do not download automatically, are listed but no longer counted as waiting.
|
||||||
|
- Artwork published on http is stored on https when its host serves it there, so the page loads it directly; the rest still comes through iPX.
|
||||||
|
- An iPhone adding the site to its home screen finds the icon at the first address it tries.
|
||||||
|
- A feed whose server hangs no longer holds up every scan: a feed gets 30 seconds, and connecting anywhere 10.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- A feed's items and files reach only the people who subscribe to it. Anyone signed in could read any feed's items, a paid feed's included, with the addresses of its files, which can carry the subscriber's key, and could play, download or delete its files.
|
||||||
|
|
||||||
|
## [0.9.1] - 2026-09-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `IPX_LOG_FORMAT=json` logs one JSON object a line, for Loki and the like: a request's method,
|
||||||
|
route, status and `duration_ms`, a scan's or download's details, a failure's kind in `error.type`
|
||||||
|
(and its HTTP status in `http.response.status_code`), and each line's `trace_id` and `span_id`.
|
||||||
|
- With `OTEL_EXPORTER_OTLP_ENDPOINT` set, the daemon sends traces of its scans, downloads and web
|
||||||
|
requests to a collector such as Tempo; a scan's shows the time each feed spends fetching, on its
|
||||||
|
artwork and in the database.
|
||||||
|
- A feed with no artwork of its own, or whose artwork is missing, shows its website's icon, or the
|
||||||
|
site's `/favicon.ico`, and follows the site when it changes. It is asked for again only when the
|
||||||
|
artwork changes or you refresh the feed.
|
||||||
|
- The refresh button turns while its feed is being checked, and the check-every-feed buttons
|
||||||
|
while any of yours is.
|
||||||
|
- Adding a website's address, with or without `https://`, subscribes to the feed that site links.
|
||||||
|
An address is checked before it is added, and anything that is neither a feed nor a page linking
|
||||||
|
one is refused with the reason, instead of being added and failing on every check.
|
||||||
|
- `/api/status` gives the number of feeds, items waiting to download and files downloaded, and
|
||||||
|
the version, for a dashboard such as Homepage.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The start-up line about the web UI being reachable off the machine says what guards it, and is a warning only when there is no Cloudflare Access in front of it.
|
||||||
|
- The feed list updates just the feed that changed, as a scan checks it or you read an item, instead of reloading the whole list every minute.
|
||||||
|
- A scan fetches several feeds at once, so a refresh no longer waits on every site in turn.
|
||||||
|
- A feed that was failing when its last subscriber left is forgotten, rather than kept with its error for good. One that worked, or has files on disk, is kept as before.
|
||||||
|
- A feed that keeps failing is checked less and less often, waiting as long as it has been failing, up to once a day; it goes back to its schedule as soon as it works. Refreshing it still checks it at once.
|
||||||
|
- `ipx list` shows each feed's id on a line of its own, labelled, under its title.
|
||||||
|
- Each browser keeps its own theme, so a phone and a desktop can differ. A browser that has not
|
||||||
|
chosen one yet starts from the theme your account had.
|
||||||
|
- The Modern theme takes its colours from the new logo: its navy, the blue of its bars and the
|
||||||
|
orange of its needle.
|
||||||
|
- The logo is in the top bar beside the add-feed button, in place of the name at the top of the
|
||||||
|
feed list, and left out on a phone, for the search box. It follows the page, dark in a dark theme
|
||||||
|
and light in a light one, as the browser tab's icon does, and hovering it shows iPX's version,
|
||||||
|
which says so between releases: 0.9.1-dev, not 0.9.0.
|
||||||
|
- The logo's needle is a brighter, redder orange that stands apart from the blue bars.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `ipx copy-db`, the one-off move from SQLite to Postgres. Going back needs only the old `state.db`.
|
||||||
|
- Starting up no longer looks for files downloaded twice before 0.6.0 read WordPress's player
|
||||||
|
links correctly; that clean-up has run.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A limit on new downloads per check means a show's newest episodes: with it set to 3, ipx no longer works back through the show's history three at a time. Set to 0, it still takes the whole back catalogue.
|
||||||
|
- With no limit on new downloads per check (`max_new_per_check = 0`), downloads work on Postgres; they failed.
|
||||||
|
- Checking a feed with a long history is much quicker: items already stored are no longer written again on every check.
|
||||||
|
- The feed list loads several times faster: it was asking the database six questions per feed.
|
||||||
|
- Artwork a feed offers only over plain http shows on the https site too.
|
||||||
|
- A failing feed is easy to spot in any theme: a mark on its artwork, what is wrong in place of
|
||||||
|
its counts, and its artwork greyed out once it has failed for a day.
|
||||||
|
- The log has no terminal colour codes when it is not going to a terminal, as in `docker logs`.
|
||||||
|
- The read button shows whether an item is read, as the pin beside it shows whether it is
|
||||||
|
pinned: a tick when read, an envelope when not. It used to show the opposite.
|
||||||
|
- In Directory and Popular, Subscribe is a plus again, not the tick that marks a feed you have.
|
||||||
|
- A feed's refresh button looks like the buttons beside it, instead of standing out filled in.
|
||||||
|
- Refreshing reads your feeds in full, instead of only asking each one whether it changed, so
|
||||||
|
a feed that has not changed is still read again.
|
||||||
|
- Pulling the item list down to check for new items shows a spinner for a couple of seconds, and
|
||||||
|
a second pull meanwhile does nothing, instead of no sign at all that the check started.
|
||||||
|
- Settings no longer lists the server's download folder, which only an admin can change, on
|
||||||
|
the admin page.
|
||||||
|
- On the Unread tab, a swipe back (or k) goes to the item you just read, instead of past it or
|
||||||
|
back to the list. The items read on the way leave the Unread tab once you close the reader.
|
||||||
|
|
||||||
## [0.9.0] - 2026-09-28
|
## [0.9.0] - 2026-09-28
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -39,14 +264,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Opened from the home screen on an iPhone, the top bar, the reader's back button and the top
|
- Opened from the home screen on an iPhone, the top bar, the reader's back button and the top
|
||||||
of the feed list keep clear of the Dynamic Island and the status bar instead of sitting under
|
of the feed list keep clear of the Dynamic Island and the status bar instead of sitting under
|
||||||
them.
|
them.
|
||||||
- Swiping to the next or previous item takes a longer swipe, a quarter of the screen, so a
|
- Swiping between items on a phone takes a quarter of the screen, so a diagonal scroll no longer
|
||||||
diagonal scroll no longer jumps ahead by accident. The item follows your finger and slides
|
jumps ahead by accident, and no longer shows the item list through the reader: the next or
|
||||||
across as it changes.
|
previous item sits beside the one you are reading and comes across with it, following your
|
||||||
- Swiping between items on a phone no longer shows the item list through the reader. The next
|
finger, the end of the list says so, and swiping right from the first item draws the reader off
|
||||||
or previous item sits beside the one you are reading and comes across with it, the end of the
|
the list, dimmed until it is uncovered. A swipe from the screen's left edge stays with the page,
|
||||||
list says so, and swiping right from the first item draws the reader off the list, dimmed
|
rather than Safari taking it as Back.
|
||||||
until it is uncovered. A swipe from the screen's left edge stays with the page, rather than
|
|
||||||
Safari taking it as Back.
|
|
||||||
|
|
||||||
## [0.8.4] - 2026-09-19
|
## [0.8.4] - 2026-09-19
|
||||||
|
|
||||||
@@ -153,6 +376,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Touch gestures: pull the item list down from its top to check the feed for new items, and
|
- Touch gestures: pull the item list down from its top to check the feed for new items, and
|
||||||
swipe the item you are reading left for the next one and right for the one before, or back
|
swipe the item you are reading left for the next one and right for the one before, or back
|
||||||
to the list from the first.
|
to the list from the first.
|
||||||
|
- A favicon: the logo, squared up, also at /favicon.ico for browsers that ask there on their own,
|
||||||
|
and on white for an iPhone's home screen.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -167,17 +392,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- On a phone, an item's files, with play and delete, sit above its show notes rather than below
|
- On a phone, an item's files, with play and delete, sit above its show notes rather than below
|
||||||
them, where long notes left them looking missing.
|
them, where long notes left them looking missing.
|
||||||
- On a phone, an item with no files goes straight to its text, without a box saying "No files".
|
- On a phone, an item with no files goes straight to its text, without a box saying "No files".
|
||||||
- The page is served minified, about a quarter smaller. Its script is now TypeScript in
|
- The page is served minified, about a quarter smaller, with its script in a file of its own,
|
||||||
`web/src`, type-checked, and built with swc; building ipx needs node.
|
`/app.js`, which your browser keeps between visits and fetches again only when an update changes
|
||||||
- The script is its own file, `/app.js`, rather than inside the page. Your browser keeps it
|
it. The script is TypeScript in `web/src`, type-checked and built with swc; building ipx needs
|
||||||
between visits and fetches it again only when an update changes it.
|
node.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Switching tabs straight after marking everything read no longer shows the previous tab's
|
- Switching tabs straight after marking everything read no longer shows the previous tab's
|
||||||
items: of two lists asked for at once, only the later one is shown.
|
items: of two lists asked for at once, only the later one is shown.
|
||||||
- ipx has a favicon: the logo, squared up, also at /favicon.ico for browsers that ask there on
|
|
||||||
their own, and on white for an iPhone's home screen.
|
|
||||||
- The file icon of an item not yet downloaded sits level with the rest of its row, instead of
|
- The file icon of an item not yet downloaded sits level with the rest of its row, instead of
|
||||||
higher than a downloaded one's.
|
higher than a downloaded one's.
|
||||||
- Images in posts from sites that refuse images to other sites' pages, such as Jeff Geerling's,
|
- Images in posts from sites that refuse images to other sites' pages, such as Jeff Geerling's,
|
||||||
@@ -188,8 +411,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
unread dot back until the next refresh.
|
unread dot back until the next refresh.
|
||||||
- On the Unread tab, the item you were reading leaves the list as soon as you move to the next
|
- On the Unread tab, the item you were reading leaves the list as soon as you move to the next
|
||||||
one, rather than a few read items lingering until a refresh cleared them.
|
one, rather than a few read items lingering until a refresh cleared them.
|
||||||
- The Log button no longer shows for a moment on every load for anyone but an admin; the server
|
|
||||||
leaves it out of their page.
|
|
||||||
- An image or link in a post given relative to the post, such as The Observation Deck's, now
|
- An image or link in a post given relative to the post, such as The Observation Deck's, now
|
||||||
points at the post's site rather than at ipx, and shows.
|
points at the post's site rather than at ipx, and shows.
|
||||||
|
|
||||||
@@ -259,10 +480,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
connection", now shows that message and is flagged as the publisher's problem, instead of two
|
connection", now shows that message and is flagged as the publisher's problem, instead of two
|
||||||
parser errors about reaching the end of input.
|
parser errors about reaching the end of input.
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- An unused icon glyph (`minus`) left over from before Unsubscribe settled on `circleMinus`.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Add a feed opened over Directory or Popular now shows its Popular list instead of staying on
|
- Add a feed opened over Directory or Popular now shows its Popular list instead of staying on
|
||||||
@@ -431,13 +648,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- A Classic theme after the 2004 Mac app, beside Dark and Light: brushed-metal toolbar, Aqua
|
- A Classic theme after the 2004 Mac app, beside Dark and Light: brushed-metal toolbar, Aqua
|
||||||
blue selection, red unread badges, a striped table and Lucida Grande. The theme button steps
|
blue selection, red unread badges, a striped table and Lucida Grande. The theme button steps
|
||||||
through all three and remembers the choice.
|
through all three and remembers the choice.
|
||||||
- A toolbar across the top, after the original iPodderX: add and unsubscribe, play, mark read
|
- A toolbar across the top, after the original iPodderX: add, unsubscribe and scan; play, mark
|
||||||
and keep for the selected item, scan, a search box for what is showing, and Settings and Log.
|
read and keep for the selected item, showing its state; a search box for what is showing; and
|
||||||
|
Settings and Log.
|
||||||
- Directory, Popular and All Subscriptions at the top of the feed list, opening in the main pane.
|
- Directory, Popular and All Subscriptions at the top of the feed list, opening in the main pane.
|
||||||
Directory lists every feed anyone here subscribes to, A to Z (`GET /api/directory`). All
|
Directory lists every feed anyone here subscribes to, A to Z (`GET /api/directory`). All
|
||||||
Subscriptions lists every item from every feed you subscribe to (`GET /api/entries`).
|
Subscriptions lists every item from every feed you subscribe to (`GET /api/entries`).
|
||||||
- Items show as a table (unread, kept, title, feed, file, size, published) with a Files pane beside
|
- Items show as a table (unread, kept, title, feed, file, size, published) with a Files pane beside
|
||||||
it, and a status bar with the totals.
|
it, and a status bar with the totals. A file's type is an icon (audio, video, image, PDF, torrent,
|
||||||
|
other), green once it is downloaded and red when the download failed, with the details in its
|
||||||
|
tooltip; its size is in KB for small files instead of "0 MB".
|
||||||
- Mark everything read from All Subscriptions, across every feed you subscribe to
|
- Mark everything read from All Subscriptions, across every feed you subscribe to
|
||||||
(`POST /api/read-all`). It asks first. All Subscriptions can also check every feed from its header.
|
(`POST /api/read-all`). It asks first. All Subscriptions can also check every feed from its header.
|
||||||
- Click a column heading in the item table to sort by it (kept, title, feed, file type, size,
|
- Click a column heading in the item table to sort by it (kept, title, feed, file type, size,
|
||||||
@@ -452,28 +672,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
items show without pressing Scan.
|
items show without pressing Scan.
|
||||||
- A file deleted to save space, or by hand, looks as if it was never downloaded: no "reaped"
|
- A file deleted to save space, or by hand, looks as if it was never downloaded: no "reaped"
|
||||||
label, just the Download button. The retention summary says "deleted", not "reaped".
|
label, just the Download button. The retention summary says "deleted", not "reaped".
|
||||||
- Buttons are icons, with the words in their tooltips: the Files pane (save, delete, view,
|
- Buttons are icons, with the words in their tooltips, one meaning per icon: minus unsubscribes,
|
||||||
download), an item's own buttons (mark read, keep, open the original), the feed header (scan,
|
x closes or cancels, plus adds or subscribes, a dialog's confirm button carries the icon of what
|
||||||
download latest, mark all read, settings, unsubscribe), and the Settings, feed settings and
|
it does, Keep is a flag, as it was in the original, mark unread is an envelope, and Subscribed is
|
||||||
Download latest dialogs (save, download, cancel). The icons are Font Awesome Free, embedded as
|
a green check. The icons are Font Awesome Free, embedded as SVG: only the ones used, no font to
|
||||||
SVG: only the ones used, no font to download, and nothing fetched from anyone else. They
|
download, and nothing fetched from anyone else. They replace words and font characters such as
|
||||||
replace font characters such as ⟳ ⤓ ↗, which came out thin and tiny and differed from font to
|
⟳ ⤓ ↗, which came out thin and tiny and differed from font to font, and the feed header's x for
|
||||||
font. Keep is a flag everywhere, as it was in the original, and mark unread is an envelope.
|
unsubscribe, which read as closing the page.
|
||||||
- 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. The feed header's unsubscribe was an x
|
|
||||||
and read as closing the page. The remaining word buttons are icons too:
|
|
||||||
- Log, Add feed, Users, Unsubscribe and OPML.
|
|
||||||
- Popular's Subscribe, Copy and Sign out.
|
|
||||||
- The Subscribed label in Popular, the Directory and Add feed, which is now a green check.
|
|
||||||
- The player's back, play, forward and close, which were font characters, and the folder arrow.
|
|
||||||
- The toolbar's read and keep buttons show the selected item's state, with the same icons as the
|
|
||||||
item's own buttons. Play, read and keep sit together, and Scan sits with add and unsubscribe.
|
|
||||||
- An OPML subscription's page has the same header as a feed's, with its buttons in the same places.
|
- An OPML subscription's page has the same header as a feed's, with its buttons in the same places.
|
||||||
- The item table's size has its own column, apart from the file's type, and shows KB for small
|
|
||||||
files instead of "0 MB". The Item heading is now Title.
|
|
||||||
- A file's type is an icon (audio, video, image, PDF, torrent, other), green once it is
|
|
||||||
downloaded and red when the download failed, with the details in its tooltip. One icon per
|
|
||||||
row keeps the column lined up. The DOWNLOADED and PENDING labels are gone.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -524,11 +730,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Importing another account's OPML export subscribed nobody and reported "Imported 0 feed(s)".
|
|
||||||
Feeds it added had no subscriber, so they were never scanned.
|
|
||||||
- Importing something that is not OPML answered `500`. It is now `400` "that is not an OPML file",
|
- Importing something that is not OPML answered `500`. It is now `400` "that is not an OPML file",
|
||||||
refused before anything changes.
|
refused before anything changes.
|
||||||
- Starring stopped protecting a file from the quota and age sweeps once read state became per-user.
|
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
@@ -542,7 +745,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Web UI served by the daemon: plain HTML and JS compiled into the binary, with feeds, items,
|
- Web UI served by the daemon: plain HTML and JS compiled into the binary, with feeds, items,
|
||||||
filters, search, sanitised show notes and live progress over SSE.
|
filters, search, sanitised show notes and live progress over SSE. Opening an item marks it
|
||||||
|
read; playing it marks it read only at the end or past 90%.
|
||||||
- Player bar with resume, speed, keyboard shortcuts and lock-screen controls.
|
- Player bar with resume, speed, keyboard shortcuts and lock-screen controls.
|
||||||
- Three-pane layout: feeds beside, items above, and the selected item's text and files below.
|
- Three-pane layout: feeds beside, items above, and the selected item's text and files below.
|
||||||
- Phone layout.
|
- Phone layout.
|
||||||
@@ -550,7 +754,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
trusted proxy header for Cloudflare Zero Trust or Authentik (`docs/sso.md`).
|
trusted proxy header for Cloudflare Zero Trust or Authentik (`docs/sso.md`).
|
||||||
- Subscribing to an OPML: it is re-read every scan and its feeds show as a folder. A feed dropped
|
- Subscribing to an OPML: it is re-read every scan and its feeds show as a folder. A feed dropped
|
||||||
from it is removed unless it has downloads.
|
from it is removed unless it has downloads.
|
||||||
- Scheduling: a global interval with per-feed overrides (`every 30m`, `4h`, `1d`, `2w`).
|
- Scheduling: a global interval with per-feed overrides (`every 30m`, `4h`, `1d`, `2w`), set by an
|
||||||
|
admin, as are the global settings.
|
||||||
- `[general] media_types`, default audio and video, and `max_new_per_check`, default 3.
|
- `[general] media_types`, default audio and video, and `max_new_per_check`, default 3.
|
||||||
- Every enclosure of an item, a View link for files that are not audio or video, and per-item
|
- Every enclosure of an item, a View link for files that are not audio or video, and per-item
|
||||||
artwork.
|
artwork.
|
||||||
@@ -563,33 +768,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Global settings and scan schedules are admin-only. The per-feed schedule picker is gone.
|
|
||||||
- Feeds from an OPML live in the database, not `config.toml`.
|
- Feeds from an OPML live in the database, not `config.toml`.
|
||||||
- Opening an item marks it read. Playing it marks it read only at the end or past 90%.
|
|
||||||
- "Episodes" became "items", since half the library is text.
|
|
||||||
- A burst of scan events causes one refresh, not one per feed.
|
|
||||||
- All is the default filter. OPML import and export, Settings and Log moved out of the header.
|
|
||||||
- Torrents run detached, two at a time.
|
- Torrents run detached, two at a time.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Download fetched the next queued episodes instead of the one clicked.
|
|
||||||
- Pressing play made an item vanish from the Unread list.
|
|
||||||
- Clearing a folder, schedule or cap from the UI did nothing.
|
|
||||||
- The daemon ignored SIGTERM until the current download finished.
|
- The daemon ignored SIGTERM until the current download finished.
|
||||||
- A missing function stopped the page script and left the whole UI dead.
|
|
||||||
- One download painted progress on every pending row.
|
|
||||||
- A torrent could freeze scanning for up to an hour.
|
- A torrent could freeze scanning for up to an hour.
|
||||||
- Downloading from an OPML feed failed with "belongs to unsubscribed feed".
|
- Image enclosures were downloaded as episodes.
|
||||||
- Image enclosures were downloaded, counted as episodes and given a play button.
|
|
||||||
- A feed whose entries had been deleted stayed empty, because the server kept answering `304`.
|
- A feed whose entries had been deleted stayed empty, because the server kept answering `304`.
|
||||||
- An item with several enclosures kept only the last.
|
- An item with several enclosures kept only the last.
|
||||||
- OPML folders rendered open by default.
|
|
||||||
- Mark read in the text pane recursed until the stack overflowed.
|
|
||||||
- The Unread, Downloaded and Flagged filters answered `500` without a search term.
|
|
||||||
- An OPML subscription always showed 0 unread.
|
|
||||||
- Folder names kept doubled spaces where separators were stripped.
|
|
||||||
- Sidebar rows had four different left edges.
|
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
@@ -613,7 +801,15 @@ 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.
|
- 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/`.
|
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||||
|
|
||||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v0.9.0...main
|
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.0.0...main
|
||||||
|
[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
|
||||||
|
[0.10.3]: https://git.sdf1.net/rays/ipx/compare/v0.10.2...v0.10.3
|
||||||
|
[0.10.2]: https://git.sdf1.net/rays/ipx/compare/v0.10.1...v0.10.2
|
||||||
|
[0.10.1]: https://git.sdf1.net/rays/ipx/compare/v0.10.0...v0.10.1
|
||||||
|
[0.10.0]: https://git.sdf1.net/rays/ipx/compare/v0.9.1...v0.10.0
|
||||||
|
[0.9.1]: https://git.sdf1.net/rays/ipx/compare/v0.9.0...v0.9.1
|
||||||
[0.9.0]: https://git.sdf1.net/rays/ipx/compare/v0.8.4...v0.9.0
|
[0.9.0]: https://git.sdf1.net/rays/ipx/compare/v0.8.4...v0.9.0
|
||||||
[0.8.4]: https://git.sdf1.net/rays/ipx/compare/v0.8.3...v0.8.4
|
[0.8.4]: https://git.sdf1.net/rays/ipx/compare/v0.8.3...v0.8.4
|
||||||
[0.8.3]: https://git.sdf1.net/rays/ipx/compare/v0.8.2...v0.8.3
|
[0.8.3]: https://git.sdf1.net/rays/ipx/compare/v0.8.2...v0.8.3
|
||||||
36
crates/ipx/Cargo.toml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
[package]
|
||||||
|
name = "ipx"
|
||||||
|
version = "1.0.0"
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ipx-feed.workspace = true
|
||||||
|
ipx-directory.workspace = true
|
||||||
|
ammonia.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
argon2.workspace = true
|
||||||
|
atom_syndication.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
futures-util.workspace = true
|
||||||
|
jsonwebtoken.workspace = true
|
||||||
|
librqbit.workspace = true
|
||||||
|
opentelemetry.workspace = true
|
||||||
|
opml.workspace = true
|
||||||
|
percent-encoding.workspace = true
|
||||||
|
quick-xml.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
rss.workspace = true
|
||||||
|
sea-orm.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
|
tower.workspace = true
|
||||||
|
tower-http.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-opentelemetry.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
url.workspace = true
|
||||||
@@ -137,7 +137,7 @@ mod tests {
|
|||||||
const AUD: &str = "aud-tag";
|
const AUD: &str = "aud-tag";
|
||||||
|
|
||||||
fn jwks() -> JwkSet {
|
fn jwks() -> JwkSet {
|
||||||
serde_json::from_str(include_str!("../tests/data/access-test.jwks.json")).unwrap()
|
serde_json::from_str(include_str!("../../../tests/data/access-test.jwks.json")).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String {
|
fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String {
|
||||||
@@ -155,8 +155,8 @@ mod tests {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIGNER: &[u8] = include_bytes!("../tests/data/access-test.der");
|
const SIGNER: &[u8] = include_bytes!("../../../tests/data/access-test.der");
|
||||||
const FORGER: &[u8] = include_bytes!("../tests/data/access-forger.der");
|
const FORGER: &[u8] = include_bytes!("../../../tests/data/access-forger.der");
|
||||||
|
|
||||||
async fn check(keys: &Keys, t: &str) -> Option<String> {
|
async fn check(keys: &Keys, t: &str) -> Option<String> {
|
||||||
keys.verify_with(TEAM, AUD, t, || async { Ok(jwks()) }).await
|
keys.verify_with(TEAM, AUD, t, || async { Ok(jwks()) }).await
|
||||||
138
crates/ipx/src/art.rs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
//! Artwork kept on disk, so the page loads it from iPX: fast after the first time, nothing asked
|
||||||
|
//! of each publisher's server for every visit, and still there when a publisher is not.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{LazyLock, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
pub fn dir() -> PathBuf {
|
||||||
|
crate::config::data_dir().join("art")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where an image's bytes are kept; its content type is beside it, in `<name>.type`.
|
||||||
|
// ponytail: std's hasher, 64 bits, keyed by the URL. Its algorithm may change with Rust, which
|
||||||
|
// only makes the cache miss once and refill; a collision among a few thousand images is about
|
||||||
|
// one in 10^12. Move to a SHA if either ever matters.
|
||||||
|
pub fn path(url: &str) -> PathBuf {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
url.hash(&mut h);
|
||||||
|
dir().join(format!("{:016x}", h.finish()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A kept image and its type, marked as just used, which is what keeps it from being trimmed.
|
||||||
|
pub fn read(file: &Path) -> Option<(String, Vec<u8>)> {
|
||||||
|
let kind = std::fs::read_to_string(file.with_extension("type")).ok()?;
|
||||||
|
let body = std::fs::read(file).ok()?;
|
||||||
|
if let Ok(f) = std::fs::File::options().write(true).open(file) {
|
||||||
|
let _ = f.set_modified(std::time::SystemTime::now());
|
||||||
|
}
|
||||||
|
Some((kind, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps an image, written aside and renamed into place, so a page asking for it meanwhile
|
||||||
|
/// never reads half of one.
|
||||||
|
pub fn write(file: &Path, kind: &str, body: &[u8]) -> std::io::Result<()> {
|
||||||
|
std::fs::create_dir_all(file.parent().unwrap_or(Path::new(".")))?;
|
||||||
|
let tmp = file.with_extension("part");
|
||||||
|
std::fs::write(&tmp, body)?;
|
||||||
|
std::fs::write(file.with_extension("type"), kind)?;
|
||||||
|
std::fs::rename(&tmp, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long an address that could not be fetched is answered from memory before it is tried again.
|
||||||
|
const RETRY: Duration = Duration::from_secs(60 * 60);
|
||||||
|
|
||||||
|
/// Addresses that could not be fetched lately, and why. Without it a page shown again fetched
|
||||||
|
/// them again: five covers too big to keep were downloaded from Patreon on every load (#169).
|
||||||
|
// ponytail: in memory, and an entry goes only when it is asked for after RETRY. It holds at most
|
||||||
|
// one per artwork address a feed or item names; move it to disk or prune it on trim if that grows.
|
||||||
|
static FAILED: LazyLock<Mutex<HashMap<String, (Instant, String)>>> = LazyLock::new(Default::default);
|
||||||
|
|
||||||
|
/// Why `url` could not be fetched, if it failed less than an hour ago.
|
||||||
|
pub fn failed(url: &str) -> Option<String> {
|
||||||
|
failed_as_of(url, Instant::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failed_as_of(url: &str, now: Instant) -> Option<String> {
|
||||||
|
let mut map = FAILED.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
match map.get(url) {
|
||||||
|
Some((at, why)) if now.duration_since(*at) < RETRY => Some(why.clone()),
|
||||||
|
Some(_) => {
|
||||||
|
map.remove(url);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fail(url: &str, why: &str) {
|
||||||
|
FAILED.lock().unwrap_or_else(|e| e.into_inner()).insert(url.to_owned(), (Instant::now(), why.to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the least recently used images until what is kept fits in `limit` bytes; 0 empties it.
|
||||||
|
/// Returns how many went.
|
||||||
|
pub fn trim(dir: &Path, limit: u64) -> usize {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else { return 0 };
|
||||||
|
let mut kept: Vec<(std::time::SystemTime, u64, PathBuf)> = entries
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| p.extension().is_none())
|
||||||
|
.filter_map(|p| {
|
||||||
|
let m = p.metadata().ok()?;
|
||||||
|
Some((m.modified().ok()?, m.len(), p))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut total: u64 = kept.iter().map(|(_, n, _)| n).sum();
|
||||||
|
kept.sort();
|
||||||
|
let mut gone = 0;
|
||||||
|
for (_, n, p) in kept {
|
||||||
|
if total <= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(p.with_extension("type"));
|
||||||
|
if std::fs::remove_file(&p).is_ok() {
|
||||||
|
total -= n;
|
||||||
|
gone += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::metrics::STATS.art_cache_bytes.store(total, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
crate::metrics::STATS.art_evicted.fetch_add(gone as u64, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
gone
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_least_recently_used_go_first_until_it_fits() {
|
||||||
|
let d = std::env::temp_dir().join(format!("ipx-art-test-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
for (name, age) in [("old", 300), ("mid", 200), ("new", 100)] {
|
||||||
|
let f = d.join(name);
|
||||||
|
write(&f, "image/png", &[0; 1000]).unwrap();
|
||||||
|
let t = std::time::SystemTime::now() - std::time::Duration::from_secs(age);
|
||||||
|
std::fs::File::options().write(true).open(&f).unwrap().set_modified(t).unwrap();
|
||||||
|
}
|
||||||
|
// Shown just now: no longer the oldest.
|
||||||
|
assert_eq!(read(&d.join("old")).unwrap().0, "image/png");
|
||||||
|
assert_eq!(trim(&d, 2000), 1);
|
||||||
|
assert!(!d.join("mid").exists() && !d.join("mid.type").exists());
|
||||||
|
assert!(d.join("old").exists() && d.join("new").exists());
|
||||||
|
assert_eq!(trim(&d, 0), 2);
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failure_is_remembered_for_an_hour() {
|
||||||
|
let u = "https://example.com/a-failure-is-remembered.png";
|
||||||
|
assert_eq!(failed(u), None);
|
||||||
|
fail(u, "larger than 5 MB");
|
||||||
|
assert_eq!(failed(u).as_deref(), Some("larger than 5 MB"));
|
||||||
|
assert_eq!(failed_as_of(u, Instant::now() + RETRY), None);
|
||||||
|
// Asked for after the hour, it is forgotten, not just ignored.
|
||||||
|
assert_eq!(failed(u), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,35 @@ pub fn verify_password(password: &str, stored: &str) -> bool {
|
|||||||
.is_ok()
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many password checks run at once: each is tens of milliseconds of CPU, and forty wrong
|
||||||
|
/// passwords at a time, run on the async workers, made every other request wait 4s (#137).
|
||||||
|
/// Half the cores, so a flood of sign-ins waits on itself and the rest of the server has the rest.
|
||||||
|
static CHECKS: std::sync::LazyLock<tokio::sync::Semaphore> = std::sync::LazyLock::new(|| {
|
||||||
|
tokio::sync::Semaphore::new(std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1)))
|
||||||
|
});
|
||||||
|
|
||||||
|
/// What a name that is not an account is checked against, so that refusing it takes as long as
|
||||||
|
/// refusing a wrong password does. Refused without a check, it came back 31ms sooner, and the
|
||||||
|
/// time told anyone which names are accounts here (#138).
|
||||||
|
static DECOY: std::sync::LazyLock<String> =
|
||||||
|
std::sync::LazyLock::new(|| hash_password("no account here has this password").expect("hashing a fixed password"));
|
||||||
|
|
||||||
|
/// A sign-in's password against the account's hash, or against `DECOY` when there is no account
|
||||||
|
/// or it has no password: false either way, in the same time. Off the async workers, and a few at
|
||||||
|
/// a time; see `CHECKS`.
|
||||||
|
pub async fn check_password(password: String, stored: Option<String>) -> bool {
|
||||||
|
let Ok(_turn) = CHECKS.acquire().await else { return false };
|
||||||
|
tokio::task::spawn_blocking(move || match stored {
|
||||||
|
Some(h) => verify_password(&password, &h),
|
||||||
|
None => {
|
||||||
|
verify_password(&password, &DECOY);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// A session id: 256 bits of urandom, hex. Long enough that guessing is not a strategy.
|
/// A session id: 256 bits of urandom, hex. Long enough that guessing is not a strategy.
|
||||||
pub fn new_session_token() -> String {
|
pub fn new_session_token() -> String {
|
||||||
let mut bytes = [0u8; 32];
|
let mut bytes = [0u8; 32];
|
||||||
@@ -40,6 +69,19 @@ pub fn new_session_token() -> String {
|
|||||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A new API token: `ipx_` and a session's 256 random bits, the prefix so one found in a log
|
||||||
|
/// or a file says what it opens (#123).
|
||||||
|
pub fn new_api_token() -> String {
|
||||||
|
format!("ipx_{}", new_session_token())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is kept of an API token. A fast hash is enough: the token is 256 random bits, not a
|
||||||
|
/// password, so there is nothing to guess from a stolen hash.
|
||||||
|
pub fn api_token_hash(token: &str) -> String {
|
||||||
|
use sha2::Digest;
|
||||||
|
sha2::Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn getrandom(buf: &mut [u8]) -> std::io::Result<()> {
|
fn getrandom(buf: &mut [u8]) -> std::io::Result<()> {
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
std::fs::File::open("/dev/urandom")?.read_exact(buf)
|
std::fs::File::open("/dev/urandom")?.read_exact(buf)
|
||||||
@@ -63,6 +105,16 @@ pub fn name_from_header(raw: &str) -> Option<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_sign_in_without_an_account_is_checked_all_the_same() {
|
||||||
|
let h = hash_password("correct horse battery").unwrap();
|
||||||
|
assert!(check_password("correct horse battery".into(), Some(h.clone())).await);
|
||||||
|
assert!(!check_password("wrong".into(), Some(h)).await);
|
||||||
|
assert!(!check_password("correct horse battery".into(), None).await);
|
||||||
|
// A decoy that does not parse is refused before any hashing, and the time says so (#138).
|
||||||
|
assert!(PasswordHash::new(&DECOY).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_password_verifies_only_against_itself() {
|
fn a_password_verifies_only_against_itself() {
|
||||||
let h = hash_password("correct horse battery").unwrap();
|
let h = hash_password("correct horse battery").unwrap();
|
||||||
@@ -74,6 +126,21 @@ mod tests {
|
|||||||
assert!(hash_password("short").is_err());
|
assert!(hash_password("short").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_tokens_are_prefixed_and_hash_to_hex() {
|
||||||
|
let t = new_api_token();
|
||||||
|
assert!(t.starts_with("ipx_") && t.len() == 68);
|
||||||
|
let h = api_token_hash(&t);
|
||||||
|
assert_eq!(h.len(), 64);
|
||||||
|
assert_eq!(h, api_token_hash(&t), "the same token, the same hash");
|
||||||
|
assert_ne!(h, api_token_hash(&new_api_token()));
|
||||||
|
assert_eq!(
|
||||||
|
api_token_hash("abc"),
|
||||||
|
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||||
|
"SHA-256"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_tokens_are_long_and_distinct() {
|
fn session_tokens_are_long_and_distinct() {
|
||||||
let a = new_session_token();
|
let a = new_session_token();
|
||||||
@@ -39,6 +39,17 @@ pub struct General {
|
|||||||
/// image in an <enclosure>, so taking everything filled the disk with artwork and
|
/// image in an <enclosure>, so taking everything filled the disk with artwork and
|
||||||
/// counted it as episodes. Empty means take anything.
|
/// counted it as episodes. Empty means take anything.
|
||||||
pub media_types: Vec<String>,
|
pub media_types: Vec<String>,
|
||||||
|
/// How much artwork iPX keeps on disk, in MB, so the page loads it from iPX rather than from
|
||||||
|
/// every publisher; the least recently shown goes first. 0 keeps none.
|
||||||
|
pub art_cache_mb: u64,
|
||||||
|
/// The directory service the Directory shows (#205), as `https://directory.ipxreader.app`. Empty
|
||||||
|
/// for none: then the Directory says so, and feeds are added by their address.
|
||||||
|
pub directory_url: String,
|
||||||
|
/// The token the directory gave this server. Never sent to the page: it says only whether
|
||||||
|
/// one is set.
|
||||||
|
pub directory_token: String,
|
||||||
|
/// Send the directory the public feeds people here subscribe to, and how many of them do.
|
||||||
|
pub directory_share: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
@@ -94,6 +105,9 @@ pub struct Web {
|
|||||||
pub sign_out_url: String,
|
pub sign_out_url: String,
|
||||||
/// Sign a session out after this long without a request.
|
/// Sign a session out after this long without a request.
|
||||||
pub session_days: i64,
|
pub session_days: i64,
|
||||||
|
/// Where to serve /metrics for Prometheus (#194), on a thread of its own, without sign-in:
|
||||||
|
/// `0.0.0.0:9187`. Empty serves none.
|
||||||
|
pub metrics_bind: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Web {
|
impl Default for Web {
|
||||||
@@ -109,6 +123,7 @@ impl Default for Web {
|
|||||||
auto_create_users: true,
|
auto_create_users: true,
|
||||||
sign_out_url: String::new(),
|
sign_out_url: String::new(),
|
||||||
session_days: 30,
|
session_days: 30,
|
||||||
|
metrics_bind: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,6 +178,14 @@ pub struct Feed {
|
|||||||
/// Preferred over `password`: name of an env var holding the password.
|
/// Preferred over `password`: name of an env var holding the password.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub password_env: Option<String>,
|
pub password_env: Option<String>,
|
||||||
|
/// Put in the Directory by an admin (`ipx add --list`): listed with no subscribers, and kept
|
||||||
|
/// in the catalogue when the last one leaves, where anyone else's feed is dropped.
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub listed: bool,
|
||||||
|
/// Never sent to the directory (#205), whatever its address looks like: someone's own
|
||||||
|
/// access to a paid show that `feed::url_looks_private` does not recognise.
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub private: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn yes() -> bool {
|
fn yes() -> bool {
|
||||||
@@ -180,6 +203,10 @@ impl Default for General {
|
|||||||
max_age_days: 0,
|
max_age_days: 0,
|
||||||
max_new_per_check: 3,
|
max_new_per_check: 3,
|
||||||
media_types: vec!["audio".into(), "video".into()],
|
media_types: vec!["audio".into(), "video".into()],
|
||||||
|
art_cache_mb: 500,
|
||||||
|
directory_url: String::new(),
|
||||||
|
directory_token: String::new(),
|
||||||
|
directory_share: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,6 +288,11 @@ impl Feed {
|
|||||||
}
|
}
|
||||||
self.password.clone()
|
self.password.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What `feed::fetch` sends the feed's host: a username, and its password if there is one.
|
||||||
|
pub fn auth(&self) -> Option<(&str, Option<String>)> {
|
||||||
|
self.username.as_deref().map(|u| (u, self.password()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -290,10 +322,33 @@ pub struct Stored {
|
|||||||
pub max_age_days: u64,
|
pub max_age_days: u64,
|
||||||
pub max_new_per_check: usize,
|
pub max_new_per_check: usize,
|
||||||
pub media_types: Vec<String>,
|
pub media_types: Vec<String>,
|
||||||
|
/// Settings saved before it existed have none: they get the default.
|
||||||
|
#[serde(default = "art_cache_mb")]
|
||||||
|
pub art_cache_mb: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub directory_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub directory_token: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub directory_share: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn art_cache_mb() -> u64 {
|
||||||
|
General::default().art_cache_mb
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `[general]` keys that live in the database once it holds the configuration.
|
/// `[general]` keys that live in the database once it holds the configuration.
|
||||||
const STORED_KEYS: [&str; 5] = ["schedule", "max_total_gb", "max_age_days", "max_new_per_check", "media_types"];
|
const STORED_KEYS: [&str; 9] = [
|
||||||
|
"schedule",
|
||||||
|
"max_total_gb",
|
||||||
|
"max_age_days",
|
||||||
|
"max_new_per_check",
|
||||||
|
"media_types",
|
||||||
|
"art_cache_mb",
|
||||||
|
"directory_url",
|
||||||
|
"directory_token",
|
||||||
|
"directory_share",
|
||||||
|
];
|
||||||
|
|
||||||
impl Stored {
|
impl Stored {
|
||||||
pub fn of(cfg: &Config) -> Self {
|
pub fn of(cfg: &Config) -> Self {
|
||||||
@@ -304,6 +359,10 @@ impl Stored {
|
|||||||
max_age_days: g.max_age_days,
|
max_age_days: g.max_age_days,
|
||||||
max_new_per_check: g.max_new_per_check,
|
max_new_per_check: g.max_new_per_check,
|
||||||
media_types: g.media_types.clone(),
|
media_types: g.media_types.clone(),
|
||||||
|
art_cache_mb: g.art_cache_mb,
|
||||||
|
directory_url: g.directory_url.clone(),
|
||||||
|
directory_token: g.directory_token.clone(),
|
||||||
|
directory_share: g.directory_share,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +373,10 @@ impl Stored {
|
|||||||
g.max_age_days = self.max_age_days;
|
g.max_age_days = self.max_age_days;
|
||||||
g.max_new_per_check = self.max_new_per_check;
|
g.max_new_per_check = self.max_new_per_check;
|
||||||
g.media_types = self.media_types;
|
g.media_types = self.media_types;
|
||||||
|
g.art_cache_mb = self.art_cache_mb;
|
||||||
|
g.directory_url = self.directory_url;
|
||||||
|
g.directory_token = self.directory_token;
|
||||||
|
g.directory_share = self.directory_share;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,6 +634,12 @@ mod tests {
|
|||||||
assert!(wanted_media(Some("image/jpeg"), &["image/jpeg".to_string()]));
|
assert!(wanted_media(Some("image/jpeg"), &["image/jpeg".to_string()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_saved_before_the_art_cache_keep_the_default() {
|
||||||
|
let old = r#"{"schedule":"every 1h","max_total_gb":0.0,"max_age_days":0,"max_new_per_check":3,"media_types":["audio"]}"#;
|
||||||
|
assert_eq!(serde_json::from_str::<Stored>(old).unwrap().art_cache_mb, 500);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn slugs_are_readable_and_unique() {
|
fn slugs_are_readable_and_unique() {
|
||||||
assert_eq!(slug("Accidental Tech Podcast"), "accidental-tech-podcast");
|
assert_eq!(slug("Accidental Tech Podcast"), "accidental-tech-podcast");
|
||||||
@@ -584,7 +653,7 @@ mod tests {
|
|||||||
taken.insert("the-daily".to_string(), Feed {
|
taken.insert("the-daily".to_string(), Feed {
|
||||||
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
||||||
auto_download: true, max_new_per_check: None, username: None,
|
auto_download: true, max_new_per_check: None, username: None,
|
||||||
password: None, password_env: None, category: None,
|
password: None, password_env: None, category: None, listed: false, private: false,
|
||||||
});
|
});
|
||||||
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
|
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
|
||||||
}
|
}
|
||||||
@@ -605,6 +674,8 @@ mod tests {
|
|||||||
password: Some("literal".into()),
|
password: Some("literal".into()),
|
||||||
password_env: None,
|
password_env: None,
|
||||||
category: None,
|
category: None,
|
||||||
|
listed: false,
|
||||||
|
private: false,
|
||||||
};
|
};
|
||||||
assert_eq!(f.password().as_deref(), Some("literal"));
|
assert_eq!(f.password().as_deref(), Some("literal"));
|
||||||
|
|
||||||
755
crates/ipx/src/deadair.rs
Normal file
@@ -0,0 +1,755 @@
|
|||||||
|
//! DeadAir (issue #171): podcasts play with their long silences shortened, so they finish
|
||||||
|
//! sooner without sounding sped up.
|
||||||
|
//!
|
||||||
|
//! Nothing is copied (#181). DeadAir finds a file's silences once, in the background, and keeps a
|
||||||
|
//! `Plan` for each pause length a person can choose (#182): the frames kept, as byte ranges of
|
||||||
|
//! the original. The player gets an HLS playlist of those ranges (`playlist`), which Safari plays
|
||||||
|
//! itself and other browsers through hls.js, with no gap at the cuts. Seeking past each silence
|
||||||
|
//! instead left about 1.3 s of nothing at every one (#178); a copy made with ffmpeg cost as much
|
||||||
|
//! disk again as the original and could only have one pause length.
|
||||||
|
//!
|
||||||
|
//! Positions stay in the original's time. What a plan leaves out is stored as `Cut`s of the
|
||||||
|
//! original's time, and the server converts both ways (`to_copy`, `to_original`): the iOS and
|
||||||
|
//! Android apps save positions themselves, from their own player, so converting in the page
|
||||||
|
//! would miss them.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tracing::Instrument;
|
||||||
|
|
||||||
|
use crate::db::step;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::Ctx;
|
||||||
|
|
||||||
|
/// Quieter than this for longer than `MIN_SILENCE` is a silence: what the measurements on the
|
||||||
|
/// issue used. A podcast's room tone sits well below it and speech well above.
|
||||||
|
const THRESHOLD: &str = "-38dB";
|
||||||
|
const MIN_SILENCE: f64 = 0.35;
|
||||||
|
/// The pauses a person can have DeadAir leave where it cuts a silence, in ms (#182). Half of it
|
||||||
|
/// is kept at each end of the silence, so the frames on both sides of a cut are silent. Decoding
|
||||||
|
/// blends a frame with the one before it, and an MP3 frame can begin in the bytes of the frame
|
||||||
|
/// before (the bit reservoir): the first frame after a cut decodes against the wrong neighbour,
|
||||||
|
/// which nobody hears between two silent frames and everyone hears at the start of a word.
|
||||||
|
pub const PAUSES: [i64; 4] = [200, 300, 400, 500];
|
||||||
|
/// 0.2 s, the first DeadAir's, made speech sound rushed.
|
||||||
|
pub const DEFAULT_PAUSE: i64 = 300;
|
||||||
|
/// The longest piece of a playlist, in seconds: about what HLS players expect, so a long stretch
|
||||||
|
/// of talk is not one request for megabytes.
|
||||||
|
const MAX_PIECE: f64 = 10.0;
|
||||||
|
/// How long the maker waits when there is nothing to do: a download or a feed turned on is
|
||||||
|
/// picked up this long after, at most.
|
||||||
|
const IDLE: std::time::Duration = std::time::Duration::from_secs(120);
|
||||||
|
|
||||||
|
/// A span of the original, in milliseconds, that DeadAir leaves out.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Cut(pub i64, pub i64);
|
||||||
|
|
||||||
|
/// What one pause length leaves of a file: what it cuts, and what it keeps as pieces of the
|
||||||
|
/// original, (byte offset, bytes, seconds).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Plan {
|
||||||
|
pub cuts: Vec<Cut>,
|
||||||
|
pub pieces: Vec<(u64, u64, f64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plan {
|
||||||
|
pub fn saved_ms(&self) -> i64 {
|
||||||
|
saved(&self.cuts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The length of the ID3v2 tag a file starts with, 0 if it has none.
|
||||||
|
pub fn id3_len(head: &[u8]) -> u64 {
|
||||||
|
if head.len() < 10 || &head[..3] != b"ID3" {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// Seven bits a byte, so no byte of the size looks like the start of a frame.
|
||||||
|
let size = head[6..10].iter().fold(0u64, |n, b| n << 7 | u64::from(b & 0x7f));
|
||||||
|
let footer = if head[5] & 0x10 != 0 { 10 } else { 0 };
|
||||||
|
10 + size + footer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file as `plan` plays it, to save (#186): its ID3 tag, so the title and artwork come too, then
|
||||||
|
/// the pieces one after another. MP3 and ADTS frames each stand alone, so that is a file any
|
||||||
|
/// player plays. Read as it is sent and never stored, as the playlist is: (its length, its bytes).
|
||||||
|
pub async fn kept(
|
||||||
|
path: &Path,
|
||||||
|
plan: &Plan,
|
||||||
|
) -> std::io::Result<(u64, impl futures_util::Stream<Item = std::io::Result<axum::body::Bytes>> + use<>)> {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
|
let mut file = tokio::fs::File::open(path).await?;
|
||||||
|
let mut head = [0u8; 10];
|
||||||
|
let n = file.read(&mut head).await?;
|
||||||
|
// Only up to the first frame kept: past the tag can be the Xing frame, which gives the
|
||||||
|
// original's length.
|
||||||
|
let first = plan.pieces.first().map_or(0, |p| p.0);
|
||||||
|
let tag = id3_len(&head[..n]).min(first);
|
||||||
|
let ranges: std::collections::VecDeque<(u64, u64)> =
|
||||||
|
std::iter::once((0, tag)).chain(plan.pieces.iter().map(|&(o, n, _)| (o, n))).filter(|r| r.1 > 0).collect();
|
||||||
|
let len = ranges.iter().map(|r| r.1).sum();
|
||||||
|
let body = futures_util::stream::try_unfold((file, ranges), |(mut f, mut left)| async move {
|
||||||
|
let Some((off, n)) = left.pop_front() else { return Ok(None) };
|
||||||
|
let mut buf = vec![0; n.min(1 << 16) as usize];
|
||||||
|
f.seek(std::io::SeekFrom::Start(off)).await?;
|
||||||
|
f.read_exact(&mut buf).await?;
|
||||||
|
let got = buf.len() as u64;
|
||||||
|
if got < n {
|
||||||
|
left.push_front((off + got, n - got));
|
||||||
|
}
|
||||||
|
Ok(Some((axum::body::Bytes::from(buf), (f, left))))
|
||||||
|
});
|
||||||
|
Ok((len, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The silences silencedetect reported, as (start, end) seconds. One still open at the end of
|
||||||
|
/// the file has no end and is left alone.
|
||||||
|
pub fn parse_silences(log: &str) -> Vec<(f64, f64)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut start = None;
|
||||||
|
for line in log.lines() {
|
||||||
|
if let Some(v) = after(line, "silence_start: ") {
|
||||||
|
start = Some(v);
|
||||||
|
} else if let Some(v) = after(line, "silence_end: ")
|
||||||
|
&& let Some(s) = start.take()
|
||||||
|
{
|
||||||
|
out.push((s, v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn after(line: &str, key: &str) -> Option<f64> {
|
||||||
|
let rest = &line[line.find(key)? + key.len()..];
|
||||||
|
rest.split_whitespace().next()?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which frames to drop for these silences, as inclusive ranges of frame numbers, and the same
|
||||||
|
/// as `Cut`s. Frame `n` plays from `first + n * frame` seconds. Only frames wholly inside a
|
||||||
|
/// silence, `keep` seconds in from each end, go.
|
||||||
|
pub fn plan(silences: &[(f64, f64)], first: f64, frame: f64, keep: f64) -> (Vec<(u64, u64)>, Vec<Cut>) {
|
||||||
|
let spans: Vec<(u64, u64)> = silences
|
||||||
|
.iter()
|
||||||
|
.filter(|(s, e)| e - s >= MIN_SILENCE)
|
||||||
|
.filter_map(|&(s, e)| {
|
||||||
|
let a = ((s + keep - first) / frame).ceil().max(0.0) as u64;
|
||||||
|
let b = ((e - keep - first) / frame).floor() as i64 - 1;
|
||||||
|
(b >= a as i64).then_some((a, b as u64))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let ms = |n: u64| ((first + n as f64 * frame) * 1000.0).round() as i64;
|
||||||
|
let cuts = spans.iter().map(|&(a, b)| Cut(ms(a), ms(b + 1))).collect();
|
||||||
|
(spans, cuts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The frames kept, as pieces of the file: runs of frames lying end to end in it, none past
|
||||||
|
/// `MAX_PIECE`. `frames` is each frame's (byte offset, bytes); `drop` is from `plan`.
|
||||||
|
pub fn pieces(frames: &[(u64, u64)], drop: &[(u64, u64)], frame: f64) -> Vec<(u64, u64, f64)> {
|
||||||
|
let most = ((MAX_PIECE / frame).floor() as usize).max(1);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let push = |out: &mut Vec<(u64, u64, f64)>, (first, n): (usize, usize)| {
|
||||||
|
let (start, (pos, size)) = (frames[first].0, frames[first + n - 1]);
|
||||||
|
out.push((start, pos + size - start, n as f64 * frame));
|
||||||
|
};
|
||||||
|
let mut drops = drop.iter().peekable();
|
||||||
|
let mut run: Option<(usize, usize)> = None;
|
||||||
|
for (n, &(pos, _)) in frames.iter().enumerate() {
|
||||||
|
while drops.peek().is_some_and(|&&(_, b)| (b as usize) < n) {
|
||||||
|
drops.next();
|
||||||
|
}
|
||||||
|
let dropped = drops.peek().is_some_and(|&&(a, _)| a as usize <= n);
|
||||||
|
// Something between two frames (an ID3 tag partway through) ends a piece too.
|
||||||
|
let joins = !dropped
|
||||||
|
&& run.is_some_and(|(f, c)| c < most && frames[f + c - 1].0 + frames[f + c - 1].1 == pos);
|
||||||
|
if !joins && let Some(r) = run.take() {
|
||||||
|
push(&mut out, r);
|
||||||
|
}
|
||||||
|
if !dropped {
|
||||||
|
run = Some(run.map_or((n, 1), |(f, c)| (f, c + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(r) = run {
|
||||||
|
push(&mut out, r);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The HLS playlist for a plan: each piece a byte range of `uri`, the original.
|
||||||
|
pub fn playlist(plan: &Plan, uri: &str) -> String {
|
||||||
|
let longest = plan.pieces.iter().map(|p| p.2).fold(0.0, f64::max);
|
||||||
|
let mut s = format!(
|
||||||
|
"#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-TARGETDURATION:{}\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-MEDIA-SEQUENCE:0\n",
|
||||||
|
longest.ceil() as u64
|
||||||
|
);
|
||||||
|
for &(off, len, secs) in &plan.pieces {
|
||||||
|
let _ = write!(s, "#EXTINF:{secs:.6},\n#EXT-X-BYTERANGE:{len}@{off}\n{uri}\n");
|
||||||
|
}
|
||||||
|
s + "#EXT-X-ENDLIST\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A time in the original, in ms, as the same moment in the shortened version. A time inside a
|
||||||
|
/// cut is where the cut was made.
|
||||||
|
pub fn to_copy(cuts: &[Cut], ms: i64) -> i64 {
|
||||||
|
let mut out = ms;
|
||||||
|
for &Cut(s, e) in cuts {
|
||||||
|
if ms >= e {
|
||||||
|
out -= e - s;
|
||||||
|
} else {
|
||||||
|
if ms > s {
|
||||||
|
out -= ms - s;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A time in the shortened version, in ms, as the same moment in the original.
|
||||||
|
pub fn to_original(cuts: &[Cut], ms: i64) -> i64 {
|
||||||
|
let mut out = ms;
|
||||||
|
for &Cut(s, e) in cuts {
|
||||||
|
if out >= s {
|
||||||
|
out += e - s;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn saved(cuts: &[Cut]) -> i64 {
|
||||||
|
cuts.iter().map(|Cut(s, e)| e - s).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether someone with these settings plays a feed shortened. Off for every feed until they
|
||||||
|
/// turn it on for that one, or for the group it came in (an OPML or a Patreon creator), which a
|
||||||
|
/// feed of the group can still say otherwise to.
|
||||||
|
pub fn wants(prefs: &HashMap<String, bool>, feed: &str, group: Option<&str>) -> bool {
|
||||||
|
prefs.get(feed).or_else(|| group.and_then(|g| prefs.get(g))).copied().unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The settings of each person with DeadAir on for something, and each managed feed's group,
|
||||||
|
/// which is what `wants` and `pause` need.
|
||||||
|
pub struct Wanting {
|
||||||
|
pub people: HashMap<i64, HashMap<String, bool>>,
|
||||||
|
pub pauses: HashMap<(i64, String), i64>,
|
||||||
|
pub groups: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Wanting {
|
||||||
|
pub async fn load(ctx: &Ctx, only: Option<i64>) -> Result<Self> {
|
||||||
|
let mut people: HashMap<i64, HashMap<String, bool>> = HashMap::new();
|
||||||
|
let mut pauses = HashMap::new();
|
||||||
|
for (user, feed, wanted, pause) in ctx.db.deadair_prefs(only).await? {
|
||||||
|
if let Some(p) = pause {
|
||||||
|
pauses.insert((user, feed.clone()), p);
|
||||||
|
}
|
||||||
|
people.entry(user).or_default().insert(feed, wanted);
|
||||||
|
}
|
||||||
|
people.retain(|_, p| p.values().any(|&w| w));
|
||||||
|
let groups = if people.is_empty() {
|
||||||
|
HashMap::new()
|
||||||
|
} else {
|
||||||
|
ctx.db.managed_feeds().await?.into_iter().map(|m| (m.id, m.group_id)).collect()
|
||||||
|
};
|
||||||
|
Ok(Self { people, pauses, groups })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wants(&self, user: i64, feed: &str) -> bool {
|
||||||
|
self.people
|
||||||
|
.get(&user)
|
||||||
|
.is_some_and(|p| wants(p, feed, self.groups.get(feed).map(String::as_str)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pause this person has DeadAir leave in a feed: the feed's, else its group's.
|
||||||
|
pub fn pause(&self, user: i64, feed: &str) -> i64 {
|
||||||
|
let group = self.groups.get(feed);
|
||||||
|
self.pauses
|
||||||
|
.get(&(user, feed.to_owned()))
|
||||||
|
.or_else(|| group.and_then(|g| self.pauses.get(&(user, g.clone()))))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(DEFAULT_PAUSE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plan someone plays a file with: their pause's, for a file in a feed they have DeadAir on
|
||||||
|
/// for, once its silences are found. None if it cuts nothing.
|
||||||
|
fn chosen(wanting: &Wanting, user: i64, feed: &str, plans: &BTreeMap<i64, Plan>) -> Option<Plan> {
|
||||||
|
if !wanting.wants(user, feed) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
plans.get(&wanting.pause(user, feed)).filter(|p| !p.cuts.is_empty()).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What each person is playing shortened, by (person, feed, guid): set when they are handed the
|
||||||
|
/// playlist and cleared when `/media` serves them the original, so a saved position is converted
|
||||||
|
/// from the clock it was measured in, whether the page saved it or a native app did.
|
||||||
|
// shortcut: in memory, so a restart forgets it. A position saved before the player next asks
|
||||||
|
// for the playlist is then taken as the original's, which resumes a little early, never late.
|
||||||
|
pub type Playing = Arc<std::sync::Mutex<HashMap<(i64, String, String), Arc<Plan>>>>;
|
||||||
|
|
||||||
|
/// The plan for one file as this person would play it, for the playlist route.
|
||||||
|
pub async fn plan_for(ctx: &Ctx, user: i64, feed: &str, enclosure: i64) -> Result<Option<Plan>> {
|
||||||
|
let wanting = Wanting::load(ctx, Some(user)).await?;
|
||||||
|
let plans = ctx.db.plans(&[enclosure]).await?;
|
||||||
|
Ok(plans.get(&enclosure).and_then(|p| chosen(&wanting, user, feed, p)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts a page of entries in the clock of what this person will play: for each file they play
|
||||||
|
/// shortened, `deadair` is the seconds it saves, and the entry's position and length are the
|
||||||
|
/// shortened version's. The page plays `/media/{id}/deadair.m3u8` for a file that has it.
|
||||||
|
pub async fn for_reader(ctx: &Ctx, user: i64, rows: &mut [crate::db::EntryRow]) -> Result<()> {
|
||||||
|
let wanting = Wanting::load(ctx, Some(user)).await?;
|
||||||
|
if wanting.people.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let ids: Vec<i64> = rows
|
||||||
|
.iter()
|
||||||
|
.filter(|r| wanting.wants(user, &r.feed_id))
|
||||||
|
.flat_map(|r| r.enclosures.iter().filter(|e| e.path.is_some()).map(|e| e.id))
|
||||||
|
.collect();
|
||||||
|
let plans = ctx.db.plans(&ids).await?;
|
||||||
|
for r in rows.iter_mut() {
|
||||||
|
// Heard through already: the maker passes it over, so nothing is coming.
|
||||||
|
let finished = r.duration.is_some_and(|d| d > 0 && r.position * 10 >= d * 9);
|
||||||
|
let mut converted = false;
|
||||||
|
for e in &mut r.enclosures {
|
||||||
|
e.deadair_pending = ids.contains(&e.id) && e.state == "done" && !plans.contains_key(&e.id) && !finished;
|
||||||
|
let Some(p) = plans.get(&e.id).and_then(|p| chosen(&wanting, user, &r.feed_id, p)) else { continue };
|
||||||
|
e.deadair = Some(p.saved_ms() / 1000);
|
||||||
|
// One position an item, however many files: the first played shortened sets the clock.
|
||||||
|
if !std::mem::replace(&mut converted, true) {
|
||||||
|
r.position = to_copy(&p.cuts, r.position * 1000) / 1000;
|
||||||
|
r.duration = r.duration.map(|d| to_copy(&p.cuts, d * 1000) / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A position and length a player saved, in the clock of what it was playing, as the
|
||||||
|
/// original's. Past 75% of the shortened version, the episode also counts towards the time
|
||||||
|
/// DeadAir has saved this person.
|
||||||
|
pub async fn on_save(
|
||||||
|
ctx: &Ctx,
|
||||||
|
playing: &Playing,
|
||||||
|
user: i64,
|
||||||
|
feed: &str,
|
||||||
|
guid: &str,
|
||||||
|
secs: i64,
|
||||||
|
duration: Option<i64>,
|
||||||
|
) -> Result<(i64, Option<i64>)> {
|
||||||
|
let plan = playing.lock().unwrap().get(&(user, feed.to_owned(), guid.to_owned())).cloned();
|
||||||
|
let Some(p) = plan else { return Ok((secs, duration)) };
|
||||||
|
let original = |s: i64| to_original(&p.cuts, s * 1000) / 1000;
|
||||||
|
let (secs, duration) = (original(secs), duration.map(original));
|
||||||
|
let len = match duration {
|
||||||
|
Some(d) => Some(d),
|
||||||
|
None => ctx.db.entry_duration(user, feed, guid).await?,
|
||||||
|
};
|
||||||
|
if let Some(len) = len
|
||||||
|
&& len > 0
|
||||||
|
&& secs * 4 >= len * 3
|
||||||
|
{
|
||||||
|
ctx.db.count_deadair(user, feed, guid, p.saved_ms()).await?;
|
||||||
|
}
|
||||||
|
Ok((secs, duration))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds silences until stopped: in the next file anyone wants shortened, or a wait of `IDLE`
|
||||||
|
/// when there is none.
|
||||||
|
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||||
|
if !have_ffmpeg().await {
|
||||||
|
tracing::warn!("ffmpeg is not installed, so DeadAir shortens nothing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Files ffmpeg failed on, passed over until the next start. Not stored: a failure recorded
|
||||||
|
// as the file having nothing to cut was never tried again, even once a fix was deployed (#174).
|
||||||
|
let mut failed = std::collections::HashSet::new();
|
||||||
|
loop {
|
||||||
|
// Dropping the pass on a signal kills the ffmpeg it is running (`kill_on_drop`).
|
||||||
|
let done = tokio::select! {
|
||||||
|
_ = stop.changed() => return,
|
||||||
|
done = next(&ctx, &mut failed) => done,
|
||||||
|
};
|
||||||
|
match done {
|
||||||
|
Ok(true) => continue,
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(e) => tracing::warn!(error = %format!("{e:#}"), "DeadAir could not read a file"),
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
_ = stop.changed() => return,
|
||||||
|
_ = ctx.deadair.notified() => {}
|
||||||
|
_ = tokio::time::sleep(IDLE) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn have_ffmpeg() -> bool {
|
||||||
|
tokio::process::Command::new("ffmpeg")
|
||||||
|
.arg("-version")
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.is_ok_and(|s| s.success())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plans one file someone wants shortened, if there is one. A file everyone who would play it
|
||||||
|
/// shortened has already heard through gets none: finished episodes are rarely played again.
|
||||||
|
// shortcut: those stay on the list and are looked at again each pass, one query each; record
|
||||||
|
// them as passed over if a large library makes the pass slow.
|
||||||
|
async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet<i64>) -> Result<bool> {
|
||||||
|
// Nobody with DeadAir on for anything, which is most passes: nothing more to ask the database.
|
||||||
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
|
let st = &crate::metrics::STATS;
|
||||||
|
let wanting = Wanting::load(ctx, None).await?;
|
||||||
|
if wanting.people.is_empty() {
|
||||||
|
st.deadair_backlog.store(0, Relaxed);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let todo = ctx.db.deadair_todo().await?;
|
||||||
|
// For /metrics (#195): files of feeds someone has DeadAir on, from memory, without the
|
||||||
|
// per-file subscription and finished checks below.
|
||||||
|
let wanted = |t: &crate::db::DeadAirTodo| !failed.contains(&t.id) && wanting.people.keys().any(|&u| wanting.wants(u, &t.feed_id));
|
||||||
|
st.deadair_backlog.store(todo.iter().filter(|t| wanted(t)).count() as i64, Relaxed);
|
||||||
|
for t in todo {
|
||||||
|
if failed.contains(&t.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let users: Vec<i64> =
|
||||||
|
wanting.people.keys().copied().filter(|&u| wanting.wants(u, &t.feed_id)).collect();
|
||||||
|
if users.is_empty() || !ctx.db.subscribed_any(&users, &t.feed_id).await? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let finished = ctx.db.finished(&t.feed_id, &t.guid).await?;
|
||||||
|
if users.iter().all(|u| finished.contains(u)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let src = crate::download::playable(Path::new(&t.path));
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
// A trace of its own for each file looked at, not for the passes that find nothing to do,
|
||||||
|
// which are most of them.
|
||||||
|
let span = tracing::info_span!("deadair", feed = t.feed_id, enclosure = t.id, cuts = tracing::field::Empty);
|
||||||
|
st.deadair_busy_since.store(crate::db::now(), Relaxed);
|
||||||
|
let analysed = analyse(&src).instrument(span.clone()).await;
|
||||||
|
st.deadair_busy_since.store(0, Relaxed);
|
||||||
|
let plans = match analysed {
|
||||||
|
Ok(p) => p.unwrap_or_default(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = %src.display(), error = %format!("{e:#}"), "DeadAir could not read a file");
|
||||||
|
failed.insert(t.id);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ctx.db.store_plans(t.id, &serde_json::to_string(&plans)?).instrument(span.in_scope(|| step("store_plans"))).await?;
|
||||||
|
let usual = plans.get(&DEFAULT_PAUSE);
|
||||||
|
span.record("cuts", usual.map_or(0, |p| p.cuts.len()));
|
||||||
|
let saved_ms = usual.map_or(0, Plan::saved_ms);
|
||||||
|
ctx.out.emit(crate::ipc::Event::DeadairDone { feed: t.feed_id.clone(), enclosure: t.id, saved_s: saved_ms / 1000 });
|
||||||
|
// The event says it too (ev="deadair_done"); this line has how long it took and how many cuts.
|
||||||
|
tracing::info!(
|
||||||
|
feed = t.feed_id,
|
||||||
|
path = %src.display(),
|
||||||
|
cuts = usual.map_or(0, |p| p.cuts.len()),
|
||||||
|
saved_s = saved_ms / 1000,
|
||||||
|
took_ms = started.elapsed().as_millis() as u64,
|
||||||
|
"DeadAir found the silences"
|
||||||
|
);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A plan for each of `PAUSES`, for one file. None for a file DeadAir does not apply to: video,
|
||||||
|
/// anything but MP3 or AAC on its own (ADTS; AAC in MP4 has no byte ranges a playlist can
|
||||||
|
/// use), or one with no silence long enough to cut.
|
||||||
|
pub async fn analyse(src: &Path) -> Result<Option<BTreeMap<i64, Plan>>> {
|
||||||
|
let Some((first, frame)) = probe(src).instrument(step("probe")).await? else { return Ok(None) };
|
||||||
|
let path = src.display().to_string();
|
||||||
|
let log = ffmpeg(&[
|
||||||
|
"-hide_banner", "-nostats", "-i", &path, "-map", "0:a:0",
|
||||||
|
"-af", &format!("silencedetect=n={THRESHOLD}:d={MIN_SILENCE}"), "-f", "null", "-",
|
||||||
|
])
|
||||||
|
.instrument(step("silencedetect"))
|
||||||
|
.await
|
||||||
|
.context("finding the silences")?;
|
||||||
|
let silences = parse_silences(&log);
|
||||||
|
let frames = frame_bytes(&path).instrument(step("frame_bytes")).await?;
|
||||||
|
let plans: BTreeMap<i64, Plan> = PAUSES
|
||||||
|
.iter()
|
||||||
|
.map(|&pause| {
|
||||||
|
let (drop, cuts) = plan(&silences, first, frame, pause as f64 / 2000.0);
|
||||||
|
(pause, Plan { cuts, pieces: pieces(&frames, &drop, frame) })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(plans.values().any(|p| !p.cuts.is_empty()).then_some(plans))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where each audio frame is in the file, as (byte offset, bytes), in order.
|
||||||
|
async fn frame_bytes(path: &str) -> Result<Vec<(u64, u64)>> {
|
||||||
|
let out = run_out("ffprobe", &[
|
||||||
|
"-v", "error", "-select_streams", "a:0", "-show_entries", "packet=pos,size", "-of", "compact=p=0", path,
|
||||||
|
])
|
||||||
|
.await?;
|
||||||
|
// ffprobe writes the fields in its own order, not the order asked for: by name, then.
|
||||||
|
out.lines()
|
||||||
|
.map(|l| {
|
||||||
|
let field = |k: &str| {
|
||||||
|
l.split('|').find_map(|f| f.strip_prefix(k)?.strip_prefix('=')?.parse::<u64>().ok())
|
||||||
|
};
|
||||||
|
field("pos").zip(field("size")).with_context(|| format!("reading ffprobe's frame: {l}"))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the first audio frame starts and how long each lasts, in seconds, for a file DeadAir
|
||||||
|
/// can shorten; None for one it cannot. A cover picture in an MP3 is not video.
|
||||||
|
async fn probe(src: &Path) -> Result<Option<(f64, f64)>> {
|
||||||
|
let path = src.display().to_string();
|
||||||
|
let out = run_out("ffprobe", &[
|
||||||
|
"-v", "error", "-show_entries",
|
||||||
|
"format=format_name:stream=codec_type,codec_name:stream_disposition=attached_pic", "-of", "json", &path,
|
||||||
|
])
|
||||||
|
.await?;
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&out).context("reading ffprobe")?;
|
||||||
|
let streams = v["streams"].as_array().cloned().unwrap_or_default();
|
||||||
|
let video = streams
|
||||||
|
.iter()
|
||||||
|
.any(|s| s["codec_type"] == "video" && s["disposition"]["attached_pic"] != 1);
|
||||||
|
let audio = streams.iter().find(|s| s["codec_type"] == "audio");
|
||||||
|
let format = v["format"]["format_name"].as_str().unwrap_or("");
|
||||||
|
let plain = |codec: &str| format == codec && audio.is_some_and(|a| a["codec_name"] == codec);
|
||||||
|
if video || !(plain("mp3") || plain("aac")) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let out = run_out("ffprobe", &[
|
||||||
|
"-v", "error", "-select_streams", "a:0", "-show_entries", "packet=pts_time,duration_time",
|
||||||
|
"-read_intervals", "%+#1", "-of", "csv=p=0", &path,
|
||||||
|
])
|
||||||
|
.await?;
|
||||||
|
let mut f = out.trim().split(',').map(|x| x.trim().parse::<f64>());
|
||||||
|
match (f.next(), f.next()) {
|
||||||
|
(Some(Ok(first)), Some(Ok(frame))) if frame > 0.0 => Ok(Some((first, frame))),
|
||||||
|
_ => bail!("no audio frame in {path}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs ffmpeg and gives back what it wrote to stderr, where silencedetect reports.
|
||||||
|
async fn ffmpeg(args: &[&str]) -> Result<String> {
|
||||||
|
let out = command("ffmpeg").args(["-nostdin", "-threads", "1"]).args(args).output().await?;
|
||||||
|
let log = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!("ffmpeg failed: {}", log.lines().rev().take(3).collect::<Vec<_>>().join(" / "));
|
||||||
|
}
|
||||||
|
Ok(log)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_out(prog: &str, args: &[&str]) -> Result<String> {
|
||||||
|
let out = command(prog).args(args).output().await.with_context(|| format!("running {prog}"))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!("{prog} failed: {}", String::from_utf8_lossy(&out.stderr).trim());
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command(prog: &str) -> tokio::process::Command {
|
||||||
|
let mut c = tokio::process::Command::new(prog);
|
||||||
|
c.kill_on_drop(true).stdin(std::process::Stdio::null());
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn silences_are_read_from_the_log_and_an_open_one_is_left() {
|
||||||
|
let log = "[silencedetect @ 0x1] silence_start: 1\n\
|
||||||
|
[silencedetect @ 0x1] silence_end: 3.00002 | silence_duration: 2.00002\n\
|
||||||
|
[silencedetect @ 0x1] silence_start: 10.5\n";
|
||||||
|
assert_eq!(parse_silences(log), vec![(1.0, 3.00002)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cuts_leave_silent_frames_either_side_and_skip_short_silences() {
|
||||||
|
// MP3 frames, 26.122 ms; one silence of 2 s, one too short to touch; a 0.3 s pause.
|
||||||
|
let frame = 1152.0 / 44100.0;
|
||||||
|
let (frames, cuts) = plan(&[(1.0, 3.0), (5.0, 5.3)], 0.0, frame, 0.15);
|
||||||
|
assert_eq!(frames, vec![(45, 108)]);
|
||||||
|
let Cut(s, e) = cuts[0];
|
||||||
|
assert!(s >= 1150 && e <= 2850, "{cuts:?}");
|
||||||
|
assert!(s - 1150 < 27 && 2850 - e < 27, "no more than a frame kept beyond half the pause: {cuts:?}");
|
||||||
|
// AAC starts a frame early, for the encoder's priming.
|
||||||
|
let (frames, _) = plan(&[(1.0, 3.0)], -0.02322, 1024.0 / 44100.0, 0.15);
|
||||||
|
assert_eq!(frames, vec![(51, 122)]);
|
||||||
|
// A silence the margins use up entirely is left alone.
|
||||||
|
assert!(plan(&[(1.0, 1.35)], 0.0, frame, 0.15).0.is_empty());
|
||||||
|
// A longer pause cuts less of the same silence.
|
||||||
|
assert!(saved(&plan(&[(1.0, 3.0)], 0.0, frame, 0.25).1) < saved(&cuts));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pieces_are_the_frames_kept_end_to_end_and_never_too_long() {
|
||||||
|
// 1000 frames of 100 bytes after a 50-byte tag, an ID3 tag of 7 bytes before frame 600.
|
||||||
|
let frames: Vec<(u64, u64)> =
|
||||||
|
(0..1000u64).map(|n| (50 + n * 100 + if n >= 600 { 7 } else { 0 }, 100)).collect();
|
||||||
|
let frame = 0.026;
|
||||||
|
let p = pieces(&frames, &[(100, 199), (900, 999)], frame);
|
||||||
|
let at: Vec<u64> = p.iter().map(|x| x.0).collect();
|
||||||
|
// 0-99; 200-583 and 584-599, split at 384 frames (10 s); 600-899 after the tag.
|
||||||
|
assert_eq!(at, [50, 20_050, 58_450, 60_057]);
|
||||||
|
assert_eq!(p[0], (50, 10_000, 100.0 * frame));
|
||||||
|
assert_eq!(p[2].1, 1600, "frames 584-599");
|
||||||
|
assert!(p.iter().all(|x| x.2 <= MAX_PIECE));
|
||||||
|
let kept: u64 = p.iter().map(|x| x.1).sum();
|
||||||
|
assert_eq!(kept, 800 * 100, "every kept frame once, the tag in none");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_playlist_lists_each_piece_as_a_range_of_the_original() {
|
||||||
|
let plan = Plan { cuts: vec![Cut(1000, 2000)], pieces: vec![(50, 400, 1.0), (500, 4000, 9.5)] };
|
||||||
|
assert_eq!(
|
||||||
|
playlist(&plan, "deadair.mp3"),
|
||||||
|
"#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-TARGETDURATION:10\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-MEDIA-SEQUENCE:0\n\
|
||||||
|
#EXTINF:1.000000,\n#EXT-X-BYTERANGE:400@50\ndeadair.mp3\n\
|
||||||
|
#EXTINF:9.500000,\n#EXT-X-BYTERANGE:4000@500\ndeadair.mp3\n#EXT-X-ENDLIST\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn positions_convert_both_ways() {
|
||||||
|
let cuts = [Cut(10_000, 20_000), Cut(30_000, 35_000)];
|
||||||
|
assert_eq!(to_copy(&cuts, 5_000), 5_000);
|
||||||
|
assert_eq!(to_copy(&cuts, 15_000), 10_000, "inside a cut is where it was made");
|
||||||
|
assert_eq!(to_copy(&cuts, 25_000), 15_000);
|
||||||
|
assert_eq!(to_copy(&cuts, 40_000), 25_000);
|
||||||
|
for t in [0, 5_000, 10_000, 15_000, 19_999, 20_000, 25_000, 40_000] {
|
||||||
|
let back = to_original(&cuts, to_copy(&cuts, t));
|
||||||
|
// A time inside a cut comes back as the cut's end: the shortened version has nothing between.
|
||||||
|
let expect = if (10_000..20_000).contains(&t) { 20_000 } else { t };
|
||||||
|
assert_eq!(back, expect, "{t}");
|
||||||
|
}
|
||||||
|
assert_eq!(saved(&cuts), 15_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn off_for_every_feed_until_turned_on_for_it_or_its_group() {
|
||||||
|
let p = |v: &[(&str, bool)]| v.iter().map(|(k, w)| (k.to_string(), *w)).collect::<HashMap<_, _>>();
|
||||||
|
assert!(!wants(&p(&[]), "a", None), "off until turned on for the feed");
|
||||||
|
assert!(!wants(&p(&[("b", true)]), "a", None), "another feed's says nothing of this one");
|
||||||
|
assert!(wants(&p(&[("a", true)]), "a", None));
|
||||||
|
assert!(wants(&p(&[("g", true)]), "a", Some("g")), "on for its group");
|
||||||
|
assert!(!wants(&p(&[("g", true), ("a", false)]), "a", Some("g")), "kept out of its group's");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_pause_is_the_feeds_then_its_groups_then_the_default() {
|
||||||
|
let w = Wanting {
|
||||||
|
people: HashMap::new(),
|
||||||
|
pauses: [((1, "a".to_string()), 500), ((1, "g".to_string()), 400)].into(),
|
||||||
|
groups: [("b".to_string(), "g".to_string())].into(),
|
||||||
|
};
|
||||||
|
assert_eq!(w.pause(1, "a"), 500);
|
||||||
|
assert_eq!(w.pause(1, "b"), 400, "its group's");
|
||||||
|
assert_eq!(w.pause(1, "c"), DEFAULT_PAUSE);
|
||||||
|
assert_eq!(w.pause(2, "a"), DEFAULT_PAUSE, "someone else's says nothing of yours");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn id3_len_reads_the_tags_size() {
|
||||||
|
assert_eq!(id3_len(b"ID3\x04\x00\x00\x00\x00\x02\x01xx"), 10 + 257);
|
||||||
|
assert_eq!(id3_len(b"ID3\x04\x00\x10\x00\x00\x02\x01"), 10 + 257 + 10, "with a footer");
|
||||||
|
assert_eq!(id3_len(b"\xff\xfb\x90\x00\x00\x00\x00\x00\x00\x00"), 0);
|
||||||
|
assert_eq!(id3_len(b"ID3"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole of it, on a clip ffmpeg makes: a second of tone, two of silence, and so on,
|
||||||
|
/// played back through its playlist by ffmpeg's own HLS reader. Skipped where ffmpeg is not
|
||||||
|
/// installed.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_playlist_of_a_file_is_shorter_by_what_was_cut_and_plays() {
|
||||||
|
if !have_ffmpeg().await {
|
||||||
|
eprintln!("no ffmpeg; skipped");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dir = std::env::temp_dir().join(format!("ipx-deadair-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
for (ext, codec) in [("mp3", "libmp3lame"), ("aac", "aac"), ("m4a", "aac")] {
|
||||||
|
let src = dir.join(format!("t.{ext}"));
|
||||||
|
ffmpeg(&[
|
||||||
|
"-v", "error", "-y", "-f", "lavfi", "-i",
|
||||||
|
"aevalsrc='if(lt(mod(t,3),1),0.5*sin(2*PI*440*t),0)':d=12:s=44100",
|
||||||
|
"-c:a", codec, &src.display().to_string(),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let plans = analyse(&src).await.unwrap();
|
||||||
|
if ext == "m4a" {
|
||||||
|
assert!(plans.is_none(), "AAC in MP4 has no byte ranges to play");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let plans = plans.expect("plans");
|
||||||
|
assert_eq!(plans.keys().copied().collect::<Vec<_>>(), PAUSES);
|
||||||
|
let p = &plans[&DEFAULT_PAUSE];
|
||||||
|
assert_eq!(p.cuts.len(), 4, "{ext}: {:?}", p.cuts);
|
||||||
|
// About 1.7 s from each of the four 2 s silences, and less for a longer pause.
|
||||||
|
let cut = p.saved_ms();
|
||||||
|
assert!((6_500..6_900).contains(&cut), "{ext}: {cut}");
|
||||||
|
assert!(plans[&500].saved_ms() < cut && cut < plans[&200].saved_ms());
|
||||||
|
let list = dir.join(format!("{ext}.m3u8"));
|
||||||
|
std::fs::write(&list, playlist(p, &format!("t.{ext}"))).unwrap();
|
||||||
|
// Its frames added up: a raw AAC file's duration is only guessed from its bitrate.
|
||||||
|
let len = |p: &Path| {
|
||||||
|
let p = p.display().to_string();
|
||||||
|
async move {
|
||||||
|
run_out("ffprobe", &["-v", "error", "-select_streams", "a:0", "-show_entries", "packet=duration_time", "-of", "csv=p=0", &p])
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.lines()
|
||||||
|
.filter_map(|l| l.trim().trim_end_matches(',').parse::<f64>().ok())
|
||||||
|
.sum::<f64>()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let short = len(&src).await - len(&list).await;
|
||||||
|
assert!((short * 1000.0 - cut as f64).abs() < 100.0, "{ext}: {short} s shorter, {cut} ms cut");
|
||||||
|
// Decodes without a complaint.
|
||||||
|
assert!(ffmpeg(&["-v", "error", "-i", &list.display().to_string(), "-f", "null", "-"]).await.unwrap().is_empty());
|
||||||
|
// Saved as a file of its own (#186): as long as the playlist, and it plays.
|
||||||
|
let (n, body) = kept(&src, p).await.unwrap();
|
||||||
|
let parts: Vec<_> = futures_util::TryStreamExt::try_collect(body).await.unwrap();
|
||||||
|
let bytes = parts.concat();
|
||||||
|
assert_eq!(bytes.len() as u64, n);
|
||||||
|
if ext == "mp3" {
|
||||||
|
assert!(bytes.starts_with(b"ID3"), "the original's tag comes too");
|
||||||
|
}
|
||||||
|
let saved = dir.join(format!("saved.{ext}"));
|
||||||
|
std::fs::write(&saved, &bytes).unwrap();
|
||||||
|
assert!((len(&saved).await - len(&list).await).abs() < 0.05, "{ext}");
|
||||||
|
assert!(ffmpeg(&["-v", "error", "-i", &saved.display().to_string(), "-f", "null", "-"]).await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A real episode, for trying the cut on something other than a test tone:
|
||||||
|
/// `IPX_DEADAIR_FILE=/path/ep.mp3 cargo test real_file -- --ignored --nocapture`. Each pause's
|
||||||
|
/// playlist is left beside it, `ep.mp3.300.m3u8`, to listen to.
|
||||||
|
#[cfg(test)]
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn real_file() {
|
||||||
|
let src = std::path::PathBuf::from(std::env::var("IPX_DEADAIR_FILE").expect("IPX_DEADAIR_FILE"));
|
||||||
|
let t = std::time::Instant::now();
|
||||||
|
let plans = analyse(&src).await.unwrap().expect("plans");
|
||||||
|
eprintln!("analysed in {:.1?}", t.elapsed());
|
||||||
|
let name = src.file_name().unwrap().to_string_lossy().into_owned();
|
||||||
|
for (pause, p) in &plans {
|
||||||
|
std::fs::write(src.with_file_name(format!("{name}.{pause}.m3u8")), playlist(p, &name)).unwrap();
|
||||||
|
eprintln!("{pause} ms: {} cuts, {:.1} min saved, {} pieces", p.cuts.len(), p.saved_ms() as f64 / 60_000.0, p.pieces.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
293
crates/ipx/src/directory.rs
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
//! The Directory, from the directory service (#205): ipx asks it on its people's behalf, so the
|
||||||
|
//! page never talks to it and never sees its token, and sends it the public feeds people here
|
||||||
|
//! subscribe to when an admin has turned sharing on.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use ipx_directory::{FeedPage, Listing, Me, SnapshotEntry, SnapshotResult};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
|
||||||
|
use crate::{Ctx, Sub, config, feed};
|
||||||
|
|
||||||
|
/// How often the snapshot goes when nothing has woken the sender sooner.
|
||||||
|
const SHARE_EVERY: std::time::Duration = std::time::Duration::from_secs(30 * 60);
|
||||||
|
|
||||||
|
/// What went wrong asking the directory, in words for an admin.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum DirError {
|
||||||
|
/// No address, or no token, in the settings.
|
||||||
|
NotConfigured,
|
||||||
|
/// It answered, refusing: 401 is a token it does not know, 403 one without the access asked for.
|
||||||
|
Refused(u16, String),
|
||||||
|
/// It did not answer, or not with what was asked for.
|
||||||
|
Unreachable(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for DirError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
DirError::NotConfigured => write!(f, "No directory is set up on this server. An admin sets one in Admin, Directory."),
|
||||||
|
DirError::Refused(401, _) => write!(f, "The directory does not accept this server's token. It may have been revoked."),
|
||||||
|
DirError::Refused(403, msg) => write!(f, "The directory refused: {msg}."),
|
||||||
|
DirError::Refused(404, _) => write!(f, "That is not in the directory."),
|
||||||
|
DirError::Refused(code, msg) => write!(f, "The directory answered {code}: {msg}"),
|
||||||
|
DirError::Unreachable(e) => write!(f, "The directory could not be reached: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the snapshot last went, and what came of it, for the admin page.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct Shared {
|
||||||
|
pub at: i64,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub result: Option<SnapshotResult>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target(cfg: &config::Config) -> Option<(String, String)> {
|
||||||
|
let url = cfg.general.directory_url.trim().trim_end_matches('/');
|
||||||
|
let token = cfg.general.directory_token.trim();
|
||||||
|
(!url.is_empty() && !token.is_empty()).then(|| (url.to_owned(), token.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn configured(cfg: &config::Config) -> bool {
|
||||||
|
target(cfg).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
// The directory's spans join this request's trace (#213).
|
||||||
|
if let Some(tp) = ipx_directory::otel::traceparent() {
|
||||||
|
req = req.header("traceparent", tp);
|
||||||
|
}
|
||||||
|
if let Some(b) = body {
|
||||||
|
req = req.json(b);
|
||||||
|
}
|
||||||
|
let resp = req.send().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let msg = resp.json::<serde_json::Value>().await.ok().and_then(|v| v["error"].as_str().map(str::to_owned)).unwrap_or_default();
|
||||||
|
return Err(DirError::Refused(status.as_u16(), msg));
|
||||||
|
}
|
||||||
|
// 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:#}")))?;
|
||||||
|
serde_json::from_slice(if body.is_empty() { b"null" } else { &body }).map_err(|e| DirError::Unreachable(format!("its answer did not read: {e:#}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seg(s: &str) -> percent_encoding::PercentEncode<'_> {
|
||||||
|
percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn me(ctx: &Ctx) -> Result<Me, DirError> {
|
||||||
|
call(ctx, reqwest::Method::GET, "/v1/me", None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn feeds(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
|
||||||
|
call(ctx, reqwest::Method::GET, "/v1/feeds", None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn popular(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
|
||||||
|
call(ctx, reqwest::Method::GET, "/v1/popular?limit=10", None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn page(ctx: &Ctx, id: &str) -> Result<FeedPage, DirError> {
|
||||||
|
call(ctx, reqwest::Method::GET, &format!("/v1/feeds/{}", seg(id)), None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Administering the directory (#206): each needs an rwa token, which the directory checks.
|
||||||
|
|
||||||
|
/// Removes a feed and blocks it, so no snapshot brings it back.
|
||||||
|
pub async fn remove(ctx: &Ctx, id: &str) -> Result<(), DirError> {
|
||||||
|
call(ctx, reqwest::Method::DELETE, &format!("/v1/feeds/{}", seg(id)), None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh(ctx: &Ctx, id: &str) -> Result<serde::de::IgnoredAny, DirError> {
|
||||||
|
call(ctx, reqwest::Method::POST, &format!("/v1/feeds/{}/refresh", seg(id)), None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn blocked(ctx: &Ctx) -> Result<Vec<ipx_directory::Blocked>, DirError> {
|
||||||
|
call(ctx, reqwest::Method::GET, "/v1/blocked", None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unblock(ctx: &Ctx, id: &str) -> Result<(), DirError> {
|
||||||
|
call(ctx, reqwest::Method::DELETE, &format!("/v1/blocked/{}", seg(id)), None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn instances(ctx: &Ctx) -> Result<Vec<ipx_directory::Instance>, DirError> {
|
||||||
|
call(ctx, reqwest::Method::GET, "/v1/instances", None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops one server's snapshot, and its counts with it. It comes back with that server's next
|
||||||
|
/// one: to keep a server out, revoke its token.
|
||||||
|
pub async fn drop_instance(ctx: &Ctx, name: &str) -> Result<(), DirError> {
|
||||||
|
call(ctx, reqwest::Method::DELETE, &format!("/v1/instances/{}", seg(name)), None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Has the snapshot sent again now, changed or not.
|
||||||
|
pub fn resend_now(ctx: &Ctx) {
|
||||||
|
ctx.directory_resend.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
ctx.directory_wake.notify_one();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this server's token may administer the directory.
|
||||||
|
pub async fn can_admin(ctx: &Ctx) -> bool {
|
||||||
|
me(ctx).await.is_ok_and(|m| m.access == ipx_directory::config::Access::Rwa)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a feed is someone's own: marked private, given a username or password, or at an
|
||||||
|
/// address that carries a key (`feed::url_looks_private`).
|
||||||
|
pub fn looks_private(feed: &config::Feed) -> bool {
|
||||||
|
feed.private
|
||||||
|
|| feed.username.is_some()
|
||||||
|
|| feed.password.is_some()
|
||||||
|
|| feed.password_env.is_some()
|
||||||
|
|| feed::url_looks_private(&feed.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the snapshot lists: every feed in the catalogue, with how many people here subscribe,
|
||||||
|
/// except an OPML or a Patreon creator itself, which is sent as the feeds in it, and anything
|
||||||
|
/// private, a feed inside a private OPML included. An OPML not read yet is not known to be one
|
||||||
|
/// and goes too; the directory lists only what it has read as a feed.
|
||||||
|
pub fn snapshot_entries(catalogue: &[Sub], counts: &HashMap<String, i64>) -> Vec<SnapshotEntry> {
|
||||||
|
let by_id: HashMap<&str, &config::Feed> = catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
|
||||||
|
let is_group: HashSet<&str> = catalogue.iter().filter_map(|s| s.cfg.group.as_deref()).collect();
|
||||||
|
catalogue
|
||||||
|
.iter()
|
||||||
|
.filter(|s| {
|
||||||
|
!is_group.contains(s.id.as_str())
|
||||||
|
&& !feed::is_patreon_creator(&s.cfg.url)
|
||||||
|
&& !looks_private(&s.cfg)
|
||||||
|
&& !s.cfg.group.as_deref().and_then(|g| by_id.get(g)).is_some_and(|f| looks_private(f))
|
||||||
|
})
|
||||||
|
.map(|s| SnapshotEntry {
|
||||||
|
url: s.cfg.url.clone(),
|
||||||
|
subscribers: counts.get(&s.id).copied().unwrap_or(0),
|
||||||
|
category: s.cfg.category.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the snapshot now.
|
||||||
|
async fn snapshot(ctx: &Ctx) -> Result<Vec<SnapshotEntry>, DirError> {
|
||||||
|
let catalogue = crate::subscriptions(ctx).await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||||
|
let counts = ctx.db.subscriber_counts().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||||
|
Ok(snapshot_entries(&catalogue, &counts))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn share(ctx: &Ctx, entries: &[SnapshotEntry]) -> Result<SnapshotResult, DirError> {
|
||||||
|
let r: SnapshotResult = call(ctx, reqwest::Method::POST, "/v1/snapshot", Some(entries)).await?;
|
||||||
|
if r.refused_private > 0 {
|
||||||
|
// The directory caught what this side should have: worth knowing which check differs.
|
||||||
|
tracing::warn!(refused = r.refused_private, "the directory refused feeds as private that ipx sent");
|
||||||
|
}
|
||||||
|
Ok(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the snapshot every half hour while sharing is on, and at once when the settings change.
|
||||||
|
pub async fn run(ctx: std::sync::Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||||
|
// What went last, where, and when: a scan wakes this every half minute or so, and the same
|
||||||
|
// 1,500 feeds went each time, the directory rewriting its rows for nothing (#211).
|
||||||
|
let mut last: Option<(Vec<u8>, std::time::Instant)> = None;
|
||||||
|
loop {
|
||||||
|
let cfg = ctx.cfg();
|
||||||
|
if cfg.general.directory_share && configured(&cfg) {
|
||||||
|
let got = match snapshot(&ctx).await {
|
||||||
|
Ok(entries) => {
|
||||||
|
let key = serde_json::to_vec(&(target(&cfg), &entries)).unwrap_or_default();
|
||||||
|
let forced = ctx.directory_resend.swap(false, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
if forced || resend(last.as_ref(), &key) {
|
||||||
|
let span = tracing::info_span!(target: "ipx::directory", "directory_share", feeds = entries.len());
|
||||||
|
let got = tracing::Instrument::instrument(share(&ctx, &entries), span).await;
|
||||||
|
if got.is_ok() {
|
||||||
|
last = Some((key, std::time::Instant::now()));
|
||||||
|
}
|
||||||
|
Some(got)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Some(Err(e)),
|
||||||
|
};
|
||||||
|
if let Some(got) = got {
|
||||||
|
match &got {
|
||||||
|
Ok(r) => tracing::info!(accepted = r.accepted, refused_private = r.refused_private, refused_blocked = r.refused_blocked, "shared with the directory"),
|
||||||
|
Err(e) => tracing::warn!(error = %e, "could not share with the directory"),
|
||||||
|
}
|
||||||
|
let (result, error) = match got {
|
||||||
|
Ok(r) => (Some(r), None),
|
||||||
|
Err(e) => (None, Some(e.to_string())),
|
||||||
|
};
|
||||||
|
*ctx.directory_shared.lock().unwrap() = Some(Shared { at: crate::db::now(), result, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(SHARE_EVERY) => {}
|
||||||
|
_ = ctx.directory_wake.notified() => {}
|
||||||
|
_ = stop.changed() => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether to send the snapshot `key` stands for: one that changed, or went to another directory
|
||||||
|
/// or with another token, goes now; the same again only once SHARE_EVERY has passed, so a
|
||||||
|
/// directory that lost it, or dropped this server's counts, has it back within the half hour.
|
||||||
|
fn resend(last: Option<&(Vec<u8>, std::time::Instant)>, key: &[u8]) -> bool {
|
||||||
|
last.is_none_or(|(k, at)| k != key || at.elapsed() >= SHARE_EVERY)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sub(id: &str, url: &str, edit: impl FnOnce(&mut config::Feed)) -> Sub {
|
||||||
|
let mut cfg: config::Feed = serde_json::from_value(serde_json::json!({ "url": url })).unwrap();
|
||||||
|
edit(&mut cfg);
|
||||||
|
Sub { id: id.into(), cfg, managed: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_snapshot_holds_public_feeds_and_never_a_private_one() {
|
||||||
|
let catalogue = vec![
|
||||||
|
sub("public", "https://example.com/feed.xml", |_| {}),
|
||||||
|
sub("marked", "https://example.com/mine.xml", |f| f.private = true),
|
||||||
|
sub("password", "https://example.com/locked.xml", |f| f.username = Some("me".into())),
|
||||||
|
sub("paid", "https://feeds.supercast.com/feeds/abc", |_| {}),
|
||||||
|
sub("keyed", "https://example.com/feed?key=k", |_| {}),
|
||||||
|
// An OPML: sent as the feeds in it, never itself.
|
||||||
|
sub("list", "https://example.com/list.opml", |_| {}),
|
||||||
|
sub("in-list", "https://example.com/a.xml", |f| f.group = Some("list".into())),
|
||||||
|
// Inside a private OPML: as private as the OPML.
|
||||||
|
sub("secret-list", "https://example.com/l.opml?token=t", |_| {}),
|
||||||
|
sub("in-secret", "https://example.com/b.xml", |f| f.group = Some("secret-list".into())),
|
||||||
|
];
|
||||||
|
let counts = HashMap::from([("public".to_string(), 3), ("marked".to_string(), 1)]);
|
||||||
|
let got = snapshot_entries(&catalogue, &counts);
|
||||||
|
let urls: Vec<(&str, i64)> = got.iter().map(|e| (e.url.as_str(), e.subscribers)).collect();
|
||||||
|
assert_eq!(urls, [("https://example.com/feed.xml", 3), ("https://example.com/a.xml", 0)]);
|
||||||
|
let patreon = vec![sub("creator", "https://www.patreon.com/c/someone", |_| {})];
|
||||||
|
assert!(snapshot_entries(&patreon, &counts).is_empty(), "a Patreon creator goes as its shows");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_is_asked_without_an_address_and_a_token() {
|
||||||
|
let mut cfg = config::Config::default();
|
||||||
|
assert!(!configured(&cfg));
|
||||||
|
cfg.general.directory_url = "https://ipx-dir.example/".into();
|
||||||
|
assert!(!configured(&cfg));
|
||||||
|
cfg.general.directory_token = "t".into();
|
||||||
|
assert_eq!(target(&cfg), Some(("https://ipx-dir.example".into(), "t".into())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_same_snapshot_is_not_sent_again_within_the_half_hour() {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
assert!(resend(None, b"a"));
|
||||||
|
assert!(!resend(Some(&(b"a".to_vec(), now)), b"a"));
|
||||||
|
assert!(resend(Some(&(b"a".to_vec(), now)), b"b"));
|
||||||
|
if let Some(long_ago) = now.checked_sub(SHARE_EVERY) {
|
||||||
|
assert!(resend(Some(&(b"a".to_vec(), long_ago)), b"a"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -172,10 +172,12 @@ pub async fn download(
|
|||||||
.with_context(|| format!("creating {}", tmp.display()))?;
|
.with_context(|| format!("creating {}", tmp.display()))?;
|
||||||
let mut done: u64 = 0;
|
let mut done: u64 = 0;
|
||||||
let mut stream = resp.bytes_stream();
|
let mut stream = resp.bytes_stream();
|
||||||
|
let _active = crate::metrics::busy(&crate::metrics::STATS.downloads_active);
|
||||||
while let Some(chunk) = stream.next().await {
|
while let Some(chunk) = stream.next().await {
|
||||||
let chunk = chunk.context("reading body")?;
|
let chunk = chunk.context("reading body")?;
|
||||||
file.write_all(&chunk).await?;
|
file.write_all(&chunk).await?;
|
||||||
done += chunk.len() as u64;
|
done += chunk.len() as u64;
|
||||||
|
crate::metrics::STATS.download_bytes.fetch_add(chunk.len() as u64, std::sync::atomic::Ordering::Relaxed);
|
||||||
on_progress(done, total);
|
on_progress(done, total);
|
||||||
}
|
}
|
||||||
file.flush().await?;
|
file.flush().await?;
|
||||||
@@ -207,6 +209,36 @@ pub async fn place(d: &Downloaded, dir: &Path) -> Result<PathBuf> {
|
|||||||
Ok(dest)
|
Ok(dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deletes what an enclosure's path names: a file, or the folder a torrent of several files came
|
||||||
|
/// down as. `remove_file` refused the folder, so the reaper warned on every pass and freed nothing,
|
||||||
|
/// and Delete failed (#158).
|
||||||
|
pub fn remove(path: &Path) -> std::io::Result<()> {
|
||||||
|
if path.is_dir() {
|
||||||
|
return std::fs::remove_dir_all(path);
|
||||||
|
}
|
||||||
|
std::fs::remove_file(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file to play for an enclosure's path: the path itself, or for a torrent of several files,
|
||||||
|
/// the largest file in its folder, which is the episode (#158).
|
||||||
|
pub fn playable(path: &Path) -> PathBuf {
|
||||||
|
fn largest(dir: &Path, best: &mut Option<(u64, PathBuf)>) {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||||
|
for e in entries.flatten() {
|
||||||
|
match e.metadata() {
|
||||||
|
Ok(m) if m.is_dir() => largest(&e.path(), best),
|
||||||
|
Ok(m) if best.as_ref().is_none_or(|(n, _)| m.len() > *n) => *best = Some((m.len(), e.path())),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut best = None;
|
||||||
|
if path.is_dir() {
|
||||||
|
largest(path, &mut best);
|
||||||
|
}
|
||||||
|
best.map_or_else(|| path.to_owned(), |(_, p)| p)
|
||||||
|
}
|
||||||
|
|
||||||
/// Advertised as a torrent, before spending any bandwidth on it.
|
/// Advertised as a torrent, before spending any bandwidth on it.
|
||||||
pub fn looks_like_torrent(url: &str, mime: Option<&str>) -> bool {
|
pub fn looks_like_torrent(url: &str, mime: Option<&str>) -> bool {
|
||||||
mime.is_some_and(|m| m.to_ascii_lowercase().contains("torrent"))
|
mime.is_some_and(|m| m.to_ascii_lowercase().contains("torrent"))
|
||||||
@@ -406,7 +438,7 @@ mod tests {
|
|||||||
let mut f = crate::config::Feed {
|
let mut f = crate::config::Feed {
|
||||||
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
|
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
|
||||||
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
|
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
|
||||||
max_new_per_check: None, username: None, password: None, password_env: None, category: None,
|
max_new_per_check: None, username: None, password: None, password_env: None, category: None, listed: false, private: false,
|
||||||
};
|
};
|
||||||
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");
|
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");
|
||||||
|
|
||||||
@@ -415,6 +447,20 @@ mod tests {
|
|||||||
assert_eq!(folder_for(&cfg, "id", &f, None), "etc/Show");
|
assert_eq!(folder_for(&cfg, "id", &f, None), "etc/Show");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_torrent_folder_plays_its_largest_file_and_deletes_whole() {
|
||||||
|
let d = std::env::temp_dir().join(format!("ipx-torrent-test-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
std::fs::create_dir_all(d.join("show/extras")).unwrap();
|
||||||
|
std::fs::write(d.join("show/cover.jpg"), [0; 10]).unwrap();
|
||||||
|
std::fs::write(d.join("show/extras/episode.mp3"), [0; 100]).unwrap();
|
||||||
|
assert_eq!(playable(&d.join("show")), d.join("show/extras/episode.mp3"));
|
||||||
|
assert_eq!(playable(&d.join("show/cover.jpg")), d.join("show/cover.jpg"), "a file is itself");
|
||||||
|
remove(&d.join("show")).unwrap();
|
||||||
|
assert!(!d.join("show").exists());
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keyword_matching_is_or_across_keywords_and_and_within_one() {
|
fn keyword_matching_is_or_across_keywords_and_and_within_one() {
|
||||||
let kws = vec!["deep dive".to_string(), "interview".to_string()];
|
let kws = vec!["deep dive".to_string(), "interview".to_string()];
|
||||||
@@ -19,6 +19,10 @@ pub mod feeds {
|
|||||||
/// The channel's first <itunes:category>, for the Directory.
|
/// The channel's first <itunes:category>, for the Directory.
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub category: Option<String>,
|
pub category: Option<String>,
|
||||||
|
/// What the feed says it is, as it says it, for its page in the Directory. Added after
|
||||||
|
/// the table was, so `create_missing` adds it to a database without it.
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub description: Option<String>,
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub etag: Option<String>,
|
pub etag: Option<String>,
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
@@ -102,6 +106,10 @@ pub mod enclosures {
|
|||||||
pub length: Option<i64>,
|
pub length: Option<i64>,
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
|
/// 'pending' is queued: a scan downloads it on its own. 'held' is listed but outside the
|
||||||
|
/// feed's newest max_new_per_check, or its feed downloads nothing automatically; it can
|
||||||
|
/// be downloaded by hand (`Db::hold_back`). 'skipped' is filtered out ('last_error' says
|
||||||
|
/// why), then 'downloading', 'done', 'error', 'reaped'.
|
||||||
#[sea_orm(column_type = "Text")]
|
#[sea_orm(column_type = "Text")]
|
||||||
pub state: String,
|
pub state: String,
|
||||||
#[sea_orm(default_value = 0)]
|
#[sea_orm(default_value = 0)]
|
||||||
@@ -283,6 +291,29 @@ pub mod sessions {
|
|||||||
owned_by_user!();
|
owned_by_user!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A token a script or agent sends as `Authorization: Bearer`, acting as the person who made
|
||||||
|
/// it (#123). Kept as its SHA-256, so the table is no use to anyone who reads it.
|
||||||
|
pub mod api_tokens {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "api_tokens")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i64,
|
||||||
|
pub user_id: i64,
|
||||||
|
/// What its owner called it, to tell one from another when revoking.
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub name: String,
|
||||||
|
#[sea_orm(unique, column_type = "Text")]
|
||||||
|
pub hash: String,
|
||||||
|
pub created: i64,
|
||||||
|
pub last_used: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
/// The catalogue: every feed configured, with its shared settings as `config::Feed` in JSON, so a
|
/// The catalogue: every feed configured, with its shared settings as `config::Feed` in JSON, so a
|
||||||
/// new setting on a feed needs no new column. It was config.toml's `[feeds]` (issue #18).
|
/// new setting on a feed needs no new column. It was config.toml's `[feeds]` (issue #18).
|
||||||
pub mod catalogue {
|
pub mod catalogue {
|
||||||
@@ -323,3 +354,67 @@ pub mod settings {
|
|||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What DeadAir found in an enclosure (#181): `plans` is a `deadair::Plan` for each pause length,
|
||||||
|
/// as JSON keyed by the pause in ms, and `{}` for a file it does not apply to (video, not MP3 or
|
||||||
|
/// AAC, nothing to cut), so that one is looked at once and not every pass.
|
||||||
|
pub mod deadair_plans {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "deadair_plans")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub enclosure_id: i64,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub plans: String,
|
||||||
|
/// Against the enclosure's `downloaded_at`: a file downloaded again is looked at again.
|
||||||
|
pub made_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who has DeadAir on, for which feeds: it is off for every feed until someone turns it on for
|
||||||
|
/// that one or its group, and `wanted` false keeps one feed of a group out of the group's.
|
||||||
|
pub mod deadair_prefs {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "deadair_prefs")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_id: i64,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
pub wanted: bool,
|
||||||
|
/// The pause DeadAir leaves where it cuts a silence, in ms (#182); NULL for the usual.
|
||||||
|
pub pause_ms: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The time DeadAir has saved someone: one row per episode they got 75% through on a copy, so
|
||||||
|
/// replaying it never counts twice. Their total is the sum.
|
||||||
|
pub mod deadair_saved {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "deadair_saved")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_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 saved_ms: i64,
|
||||||
|
pub at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@ pub enum Event {
|
|||||||
FeedSkip { feed: String, reason: String },
|
FeedSkip { feed: String, reason: String },
|
||||||
FeedDone { feed: String, new: usize, downloaded: usize, failed: usize, torrents: usize },
|
FeedDone { feed: String, new: usize, downloaded: usize, failed: usize, torrents: usize },
|
||||||
FeedError { feed: String, msg: String },
|
FeedError { feed: String, msg: String },
|
||||||
|
/// The feed answered from a new address after redirects that all said it moved for good,
|
||||||
|
/// and the catalogue now has that address (#115).
|
||||||
|
FeedMoved { feed: String, from: String, to: String },
|
||||||
Progress {
|
Progress {
|
||||||
feed: String,
|
feed: String,
|
||||||
/// Which enclosure this is about. Without it a UI cannot tell one download's
|
/// Which enclosure this is about. Without it a UI cannot tell one download's
|
||||||
@@ -29,6 +32,8 @@ pub enum Event {
|
|||||||
DownloadDone { feed: String, enclosure: i64, url: String, path: String, bytes: u64 },
|
DownloadDone { feed: String, enclosure: i64, url: String, path: String, bytes: u64 },
|
||||||
DownloadError { feed: String, enclosure: i64, url: String, msg: String },
|
DownloadError { feed: String, enclosure: i64, url: String, msg: String },
|
||||||
TorrentDeferred { feed: String, url: String },
|
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 },
|
||||||
Reaped { path: String, bytes: u64 },
|
Reaped { path: String, bytes: u64 },
|
||||||
/// Terminal: a client that asked for work stops reading here.
|
/// Terminal: a client that asked for work stops reading here.
|
||||||
ScanDone { feeds: usize },
|
ScanDone { feeds: usize },
|
||||||
@@ -50,6 +55,7 @@ impl Event {
|
|||||||
"{feed}: {new} new entries, {downloaded} downloaded, {failed} failed, {torrents} torrents deferred"
|
"{feed}: {new} new entries, {downloaded} downloaded, {failed} failed, {torrents} torrents deferred"
|
||||||
),
|
),
|
||||||
Event::FeedError { feed, msg } => format!("{feed}: error: {msg}"),
|
Event::FeedError { feed, msg } => format!("{feed}: error: {msg}"),
|
||||||
|
Event::FeedMoved { feed, from, to } => format!("{feed}: moved for good from {from} to {to}; following it"),
|
||||||
Event::Progress { file, done, total, .. } => match total {
|
Event::Progress { file, done, total, .. } => match total {
|
||||||
Some(t) if *t > 0 => format!(
|
Some(t) if *t > 0 => format!(
|
||||||
" {file}: {:.1}% ({:.1}/{:.1} MB)",
|
" {file}: {:.1}% ({:.1}/{:.1} MB)",
|
||||||
@@ -69,12 +75,13 @@ impl Event {
|
|||||||
*bytes as f64 / 1_048_576.0
|
*bytes as f64 / 1_048_576.0
|
||||||
),
|
),
|
||||||
Event::Status { feeds, pending, downloaded } => {
|
Event::Status { feeds, pending, downloaded } => {
|
||||||
format!("{feeds} feeds, {pending} pending, {downloaded} downloaded")
|
format!("{feeds} feeds, {pending} queued to download, {downloaded} downloaded")
|
||||||
}
|
}
|
||||||
Event::Error { msg } => format!("error: {msg}"),
|
Event::Error { msg } => format!("error: {msg}"),
|
||||||
// Noise in a terminal; a UI still gets them on the socket.
|
// Noise in a terminal; a UI still gets them on the socket.
|
||||||
Event::FeedStart { feed } => format!("{feed}: checking"),
|
Event::FeedStart { feed } => format!("{feed}: checking"),
|
||||||
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
|
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::ScanDone { feeds } => format!("scan complete, {feeds} feed(s)"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -105,6 +112,11 @@ pub enum Command {
|
|||||||
enclosure: i64,
|
enclosure: i64,
|
||||||
},
|
},
|
||||||
Status,
|
Status,
|
||||||
|
/// Read the catalogue and server settings again from the database, which `ipx add`, `rm`
|
||||||
|
/// and `import` change in a process of their own. A daemon keeps them in memory, and with
|
||||||
|
/// its old copy wrote a removed feed back, and dropped an added one, at its next change of
|
||||||
|
/// its own (#120). Answered, as status is, with the counts after it.
|
||||||
|
Reload,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where events go: the socket, the terminal, or both.
|
/// Where events go: the socket, the terminal, or both.
|
||||||
@@ -131,38 +143,13 @@ impl Emitter {
|
|||||||
// Level by how much it matters. With 80-odd feeds in an OPML subscription, one
|
// Level by how much it matters. With 80-odd feeds in an OPML subscription, one
|
||||||
// line per feed per tick for "not due yet" would push everything worth reading
|
// line per feed per tick for "not due yet" would push everything worth reading
|
||||||
// out of the buffer within a few minutes.
|
// out of the buffer within a few minutes.
|
||||||
let routine = match &e {
|
log_event(&e, self.tx.is_some());
|
||||||
Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } => true,
|
// A feed not due changes nothing a listener shows, and a scan has one for nearly every
|
||||||
Event::FeedDone { new, downloaded, failed, torrents, .. } => {
|
// feed: 1,500 at once overflowed the channel's 1,024 for a page slow to read (#197).
|
||||||
*new == 0 && *downloaded == 0 && *failed == 0 && *torrents == 0
|
let routine = matches!(&e, Event::FeedSkip { reason, .. } if reason.starts_with("not due"));
|
||||||
}
|
if let Some(tx) = &self.tx
|
||||||
_ => false,
|
&& !routine
|
||||||
};
|
{
|
||||||
let bad = matches!(
|
|
||||||
&e,
|
|
||||||
Event::FeedError { .. } | Event::DownloadError { .. } | Event::Error { .. }
|
|
||||||
);
|
|
||||||
if let Some(line) = e.human() {
|
|
||||||
let line = line.trim();
|
|
||||||
if bad {
|
|
||||||
tracing::warn!(target: "ipx::scan", "{line}");
|
|
||||||
} else if routine {
|
|
||||||
tracing::debug!(target: "ipx::scan", "{line}");
|
|
||||||
} else {
|
|
||||||
tracing::info!(target: "ipx::scan", "{line}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(tx) = &self.tx {
|
|
||||||
// The outbound half of the protocol, as it goes on the wire. Progress is the
|
|
||||||
// high-volume one, so it sits at debug.
|
|
||||||
if let Ok(json) = serde_json::to_string(&e) {
|
|
||||||
if matches!(e, Event::Progress { .. }) {
|
|
||||||
tracing::debug!(target: "ipx::io", "<- {json}");
|
|
||||||
} else {
|
|
||||||
tracing::info!(target: "ipx::io", "<- {json}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// An error here only means nobody is listening yet.
|
// An error here only means nobody is listening yet.
|
||||||
let _ = tx.send(e.clone());
|
let _ = tx.send(e.clone());
|
||||||
}
|
}
|
||||||
@@ -172,18 +159,77 @@ impl Emitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
/// Level by how much it matters: with 80-odd feeds in an OPML subscription, a line per feed per
|
||||||
|
/// tick for "not due yet" would push everything worth reading out of the log view in minutes,
|
||||||
|
/// and Progress fires on every whole percent. `wire` also logs the event as it goes on the
|
||||||
|
/// socket, at debug: the admin page's Daemon I/O tab shows it, production's log leaves it out.
|
||||||
|
fn log_event(e: &Event, wire: bool) {
|
||||||
|
let json = serde_json::to_string(e).unwrap_or_default();
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&json).unwrap_or_default();
|
||||||
|
let s = |k: &str| v.get(k).and_then(|x| x.as_str());
|
||||||
|
let n = |k: &str| v.get(k).and_then(|x| x.as_u64());
|
||||||
|
let (kind, code) = match e {
|
||||||
|
Event::FeedError { msg, .. } | Event::DownloadError { msg, .. } | Event::Error { msg } => {
|
||||||
|
let (k, c) = crate::feed::failure_kind(msg);
|
||||||
|
(Some(k), c)
|
||||||
|
}
|
||||||
|
_ => (None, None),
|
||||||
|
};
|
||||||
|
let routine = match e {
|
||||||
|
Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } => true,
|
||||||
|
Event::FeedDone { new, downloaded, failed, torrents, .. } => {
|
||||||
|
*new == 0 && *downloaded == 0 && *failed == 0 && *torrents == 0
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
macro_rules! line {
|
||||||
|
($level:ident, $target:literal, $text:expr) => {
|
||||||
|
tracing::$level!(
|
||||||
|
target: $target,
|
||||||
|
ev = s("ev"), feed = s("feed"), msg = s("msg"), url = s("url"), reason = s("reason"), from = s("from"), to = s("to"),
|
||||||
|
new = n("new"), downloaded = n("downloaded"), failed = n("failed"),
|
||||||
|
torrents = n("torrents"), bytes = n("bytes"), feeds = n("feeds"),
|
||||||
|
pending = n("pending"), enclosure = n("enclosure"), files = n("files"),
|
||||||
|
"error.type" = kind, "http.response.status_code" = code,
|
||||||
|
"{}", $text
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// The healthcheck's answer, every 30s: 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}"));
|
||||||
|
}
|
||||||
|
if wire {
|
||||||
|
tracing::debug!(target: "ipx::io", "<- {json}");
|
||||||
|
}
|
||||||
|
let text = e.human().map(|l| l.trim().to_owned()).unwrap_or_else(|| json.clone());
|
||||||
|
if kind.is_some() {
|
||||||
|
line!(warn, "ipx::scan", text)
|
||||||
|
} else if routine {
|
||||||
|
line!(debug, "ipx::scan", text)
|
||||||
|
} else {
|
||||||
|
line!(info, "ipx::scan", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// True when something is already listening -- i.e. a daemon owns this socket.
|
/// True when something is already listening -- i.e. a daemon owns this socket.
|
||||||
pub async fn daemon_is_live(path: &Path) -> bool {
|
pub async fn daemon_is_live(path: &Path) -> bool {
|
||||||
UnixStream::connect(path).await.is_ok()
|
UnixStream::connect(path).await.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Answers `status` for the socket, without the worker. The worker runs one job at a time, and a
|
/// Answers `status` and `reload` for the socket, without the worker. The worker runs one job at a
|
||||||
/// healthcheck left waiting behind a scan or a long download timed out and called a busy daemon
|
/// time, and a healthcheck left waiting behind a scan or a long download timed out and called a
|
||||||
/// dead. The answer goes to the client that asked and no one else: broadcast, it ended any
|
/// busy daemon dead; a reload left waiting would let the web UI write the old catalogue back in
|
||||||
/// `ipx fetch` that was watching a scan, since `status` is a terminal event.
|
/// the meantime. The answer goes to the client that asked and no one else: broadcast, it ended
|
||||||
|
/// any `ipx fetch` that was watching a scan, since `status` is a terminal event.
|
||||||
/// A future, since reading the counts is a database query.
|
/// A future, since reading the counts is a database query.
|
||||||
pub type StatusFn =
|
pub type StatusFn = std::sync::Arc<
|
||||||
std::sync::Arc<dyn Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = Event> + Send>> + Send + Sync>;
|
dyn Fn(Command) -> std::pin::Pin<Box<dyn std::future::Future<Output = Event> + Send>> + Send + Sync,
|
||||||
|
>;
|
||||||
|
|
||||||
/// Accepts connections, feeding commands to `cmds` and events from `events` back out.
|
/// Accepts connections, feeding commands to `cmds` and events from `events` back out.
|
||||||
pub async fn serve(
|
pub async fn serve(
|
||||||
@@ -254,12 +300,10 @@ async fn handle(
|
|||||||
}
|
}
|
||||||
match serde_json::from_str::<Command>(line) {
|
match serde_json::from_str::<Command>(line) {
|
||||||
// Answered here, not queued behind whatever the worker is on: see StatusFn.
|
// Answered here, not queued behind whatever the worker is on: see StatusFn.
|
||||||
Ok(Command::Status) => {
|
Ok(cmd @ (Command::Status | Command::Reload)) => {
|
||||||
tracing::info!(target: "ipx::io", "-> {line}");
|
tracing::info!(target: "ipx::io", "-> {line}");
|
||||||
let ev = status().await;
|
let ev = status(cmd).await;
|
||||||
if let Ok(json) = serde_json::to_string(&ev) {
|
log_event(&ev, true);
|
||||||
tracing::info!(target: "ipx::io", "<- {json}");
|
|
||||||
}
|
|
||||||
let _ = reply.send(ev).await;
|
let _ = reply.send(ev).await;
|
||||||
}
|
}
|
||||||
Ok(cmd) => {
|
Ok(cmd) => {
|
||||||
@@ -274,6 +318,26 @@ async fn handle(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tells a running daemon to read the catalogue again (see Command::Reload), and waits for it.
|
||||||
|
pub async fn reload(path: &Path) -> Result<()> {
|
||||||
|
let stream = UnixStream::connect(path).await?;
|
||||||
|
let (read, mut write) = stream.into_split();
|
||||||
|
write.write_all(b"{\"cmd\":\"reload\"}\n").await?;
|
||||||
|
let mut lines = BufReader::new(read).lines();
|
||||||
|
let answer = async {
|
||||||
|
// Other clients' events come here too; the answer is the status, or an error.
|
||||||
|
while let Some(line) = lines.next_line().await? {
|
||||||
|
match serde_json::from_str::<Event>(&line) {
|
||||||
|
Ok(Event::Status { .. }) => return Ok(()),
|
||||||
|
Ok(Event::Error { msg }) => anyhow::bail!("{msg}"),
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anyhow::bail!("the daemon closed the connection without answering")
|
||||||
|
};
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(10), answer).await.context("the daemon did not answer in 10s")?
|
||||||
|
}
|
||||||
|
|
||||||
/// Sends one command to a running daemon and prints the events it produces.
|
/// Sends one command to a running daemon and prints the events it produces.
|
||||||
pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> {
|
pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> {
|
||||||
let stream = UnixStream::connect(path).await?;
|
let stream = UnixStream::connect(path).await?;
|
||||||
@@ -303,6 +367,25 @@ pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_feed_not_due_stays_off_the_live_updates() {
|
||||||
|
let (tx, mut rx) = broadcast::channel(4);
|
||||||
|
let out = Emitter::socket(tx, false);
|
||||||
|
for i in 0..10 {
|
||||||
|
out.emit(Event::FeedSkip { feed: format!("f{i}"), reason: "not due for 5m".into() });
|
||||||
|
}
|
||||||
|
out.emit(Event::FeedSkip { feed: "g".into(), reason: "not modified".into() });
|
||||||
|
assert!(matches!(rx.try_recv(), Ok(Event::FeedSkip { feed, .. }) if feed == "g"), "nothing missed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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() };
|
||||||
|
let wire = serde_json::to_value(&e).unwrap();
|
||||||
|
assert_eq!((wire["ev"].as_str(), wire["from"].as_str(), wire["to"].as_str()), (Some("feed_moved"), Some("http://a/f"), Some("https://a/f")));
|
||||||
|
assert_eq!(e.human().unwrap(), "x: moved for good from http://a/f to https://a/f; following it");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn commands_parse_from_the_wire_form() {
|
fn commands_parse_from_the_wire_form() {
|
||||||
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
|
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
|
||||||
@@ -374,7 +457,7 @@ mod tests {
|
|||||||
// would end its session.
|
// would end its session.
|
||||||
let mut watcher = events.subscribe();
|
let mut watcher = events.subscribe();
|
||||||
let status: StatusFn =
|
let status: StatusFn =
|
||||||
std::sync::Arc::new(|| Box::pin(async { Event::Status { feeds: 1, pending: 2, downloaded: 3 } }));
|
std::sync::Arc::new(|_| Box::pin(async { Event::Status { feeds: 1, pending: 2, downloaded: 3 } }));
|
||||||
let (client, server) = UnixStream::pair().unwrap();
|
let (client, server) = UnixStream::pair().unwrap();
|
||||||
tokio::spawn(handle(server, events.subscribe(), cmds, status));
|
tokio::spawn(handle(server, events.subscribe(), cmds, status));
|
||||||
|
|
||||||
305
crates/ipx/src/metrics.rs
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
//! Prometheus metrics for what logs and traces cannot show: the daemon's own state between
|
||||||
|
//! requests, such as how many database connections are in use (#194). Served on a port and a
|
||||||
|
//! thread of their own, with a runtime of their own, and made only of what is already in memory:
|
||||||
|
//! a scrape neither waits for the web server's threads or the database, whose pool running dry
|
||||||
|
//! is one of the things it is there to show, nor holds anything up itself. No sign-in, as
|
||||||
|
//! Prometheus exporters have none: checking a token is a database lookup. Written out by hand:
|
||||||
|
//! the text format is a few lines, and a client library would be a dependency for them.
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering::Relaxed};
|
||||||
|
|
||||||
|
/// What the daemon counts as it works, for /metrics to read without asking anything (#195).
|
||||||
|
/// Global: the web layer, the worker, downloads, artwork and DeadAir each add to it, and a handle
|
||||||
|
/// threaded through all of them was more code than the counts.
|
||||||
|
pub static STATS: Stats = Stats::new();
|
||||||
|
|
||||||
|
pub struct Stats {
|
||||||
|
pub http_in_flight: AtomicI64,
|
||||||
|
/// Live updates dropped for a page or socket client that fell behind the broadcast.
|
||||||
|
pub events_missed: AtomicU64,
|
||||||
|
pub downloads_active: AtomicI64,
|
||||||
|
pub download_bytes: AtomicU64,
|
||||||
|
/// Feeds a scan is fetching at this moment, up to its AHEAD.
|
||||||
|
pub feeds_fetching: AtomicI64,
|
||||||
|
/// When a scan last finished, Unix seconds; 0 for not since starting.
|
||||||
|
pub last_scan_done: AtomicI64,
|
||||||
|
/// Files someone wants shortened that DeadAir has not looked at, as of its last pass.
|
||||||
|
pub deadair_backlog: AtomicI64,
|
||||||
|
/// When DeadAir started on the file it is on, Unix seconds; 0 when it is on none.
|
||||||
|
pub deadair_busy_since: AtomicI64,
|
||||||
|
pub art_cache_bytes: AtomicU64,
|
||||||
|
/// Images the sweep threw out to keep under the limit: kept bytes sit at the limit by design,
|
||||||
|
/// so this is what says whether the limit is too small.
|
||||||
|
pub art_evicted: AtomicU64,
|
||||||
|
/// 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.
|
||||||
|
pub feeds: AtomicI64,
|
||||||
|
pub pending: AtomicI64,
|
||||||
|
pub downloaded: AtomicI64,
|
||||||
|
/// The worker's job now, and since when.
|
||||||
|
job: std::sync::Mutex<Option<(&'static str, std::time::Instant)>>,
|
||||||
|
/// Jobs finished, by kind: how many, and their seconds all told.
|
||||||
|
jobs: std::sync::Mutex<std::collections::BTreeMap<&'static str, (u64, f64)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stats {
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
http_in_flight: AtomicI64::new(0),
|
||||||
|
events_missed: AtomicU64::new(0),
|
||||||
|
downloads_active: AtomicI64::new(0),
|
||||||
|
download_bytes: AtomicU64::new(0),
|
||||||
|
feeds_fetching: AtomicI64::new(0),
|
||||||
|
last_scan_done: AtomicI64::new(0),
|
||||||
|
deadair_backlog: AtomicI64::new(0),
|
||||||
|
deadair_busy_since: AtomicI64::new(0),
|
||||||
|
art_cache_bytes: AtomicU64::new(0),
|
||||||
|
art_evicted: AtomicU64::new(0),
|
||||||
|
art_hits: AtomicU64::new(0),
|
||||||
|
art_misses: AtomicU64::new(0),
|
||||||
|
feeds: AtomicI64::new(-1),
|
||||||
|
pending: AtomicI64::new(-1),
|
||||||
|
downloaded: AtomicI64::new(-1),
|
||||||
|
job: std::sync::Mutex::new(None),
|
||||||
|
jobs: std::sync::Mutex::new(std::collections::BTreeMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the worker as on a job of `kind` until what it returns is dropped, however the job
|
||||||
|
/// ends: finished, failed or stopped.
|
||||||
|
pub fn job(&'static self, kind: &'static str) -> Job {
|
||||||
|
*self.job.lock().unwrap() = Some((kind, std::time::Instant::now()));
|
||||||
|
Job(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Job(&'static Stats);
|
||||||
|
|
||||||
|
impl Drop for Job {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some((kind, since)) = self.0.job.lock().unwrap().take() {
|
||||||
|
let mut jobs = self.0.jobs.lock().unwrap();
|
||||||
|
let e = jobs.entry(kind).or_default();
|
||||||
|
e.0 += 1;
|
||||||
|
e.1 += since.elapsed().as_secs_f64();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts one more of something in progress until dropped, however it ends.
|
||||||
|
pub struct Busy(&'static AtomicI64);
|
||||||
|
|
||||||
|
pub fn busy(gauge: &'static AtomicI64) -> Busy {
|
||||||
|
gauge.fetch_add(1, Relaxed);
|
||||||
|
Busy(gauge)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Busy {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.fetch_sub(1, Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the metrics are read from.
|
||||||
|
pub struct Sources {
|
||||||
|
pub ctx: Arc<crate::Ctx>,
|
||||||
|
pub cmds: tokio::sync::mpsc::Sender<crate::ipc::Command>,
|
||||||
|
pub events: tokio::sync::broadcast::Sender<crate::ipc::Event>,
|
||||||
|
pub playing: crate::deadair::Playing,
|
||||||
|
/// The daemon's runtime, not the metrics thread's own.
|
||||||
|
pub runtime: tokio::runtime::Handle,
|
||||||
|
pub started: std::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts serving /metrics on `bind`, on a thread of its own, and says where.
|
||||||
|
pub fn spawn(bind: &str, s: Sources) -> anyhow::Result<std::net::SocketAddr> {
|
||||||
|
use anyhow::Context as _;
|
||||||
|
// Bound here, so a port already taken is the daemon's error at start, not a thread's later.
|
||||||
|
let listener = std::net::TcpListener::bind(bind).with_context(|| format!("binding metrics to {bind}"))?;
|
||||||
|
listener.set_nonblocking(true)?;
|
||||||
|
let addr = listener.local_addr()?;
|
||||||
|
let s = Arc::new(s);
|
||||||
|
std::thread::Builder::new().name("metrics".into()).spawn(move || {
|
||||||
|
let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(e) => return tracing::error!(error = %e, "metrics could not start"),
|
||||||
|
};
|
||||||
|
rt.block_on(async move {
|
||||||
|
let app = axum::Router::new().route(
|
||||||
|
"/metrics",
|
||||||
|
axum::routing::get(move || {
|
||||||
|
let s = s.clone();
|
||||||
|
async move { ([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(&s)) }
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
match tokio::net::TcpListener::from_std(listener) {
|
||||||
|
Ok(l) => {
|
||||||
|
if let Err(e) = axum::serve(l, app).await {
|
||||||
|
tracing::error!(error = %e, "metrics stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::error!(error = %e, "metrics could not listen"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})?;
|
||||||
|
Ok(addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every metric, from memory alone.
|
||||||
|
pub fn render(s: &Sources) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let (open, idle, max) = s.ctx.db.pool();
|
||||||
|
gauge(&mut out, "ipx_db_connections", "Database connections open, in use or idle.",
|
||||||
|
&[("state=\"in_use\"", open as f64 - idle as f64), ("state=\"idle\"", idle as f64)]);
|
||||||
|
gauge(&mut out, "ipx_db_connections_max", "The most database connections the pool opens.", &[("", max as f64)]);
|
||||||
|
gauge(&mut out, "ipx_worker_queue", "Commands waiting for the worker: scans, downloads and the like.",
|
||||||
|
&[("", (s.cmds.max_capacity() - s.cmds.capacity()) as f64)]);
|
||||||
|
gauge(&mut out, "ipx_event_listeners", "Open pages and socket clients listening for live updates.",
|
||||||
|
&[("", s.events.receiver_count() as f64)]);
|
||||||
|
gauge(&mut out, "ipx_torrent_slots_free", "Torrents that could start now.",
|
||||||
|
&[("", s.ctx.torrent_slots.available_permits() as f64)]);
|
||||||
|
gauge(&mut out, "ipx_deadair_playing", "Episodes someone is playing through DeadAir's playlist.",
|
||||||
|
&[("", s.playing.lock().map_or(0, |p| p.len()) as f64)]);
|
||||||
|
work(&mut out, &STATS, s.events.len(), s.ctx.retry.lock().map_or(0, |r| r.len()), s.ctx.cfg().general.art_cache_mb * 1_048_576);
|
||||||
|
let rt = s.runtime.metrics();
|
||||||
|
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();
|
||||||
|
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)]);
|
||||||
|
}
|
||||||
|
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)]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The work in progress and done, from `STATS`: jobs, queues and what is in flight (#195).
|
||||||
|
fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_limit: u64) {
|
||||||
|
let now = crate::db::now();
|
||||||
|
let job = *st.job.lock().unwrap();
|
||||||
|
gauge(out, "ipx_worker_busy", "1 while the worker is on a job.", &[("", job.is_some() as u8 as f64)]);
|
||||||
|
if let Some((kind, since)) = job {
|
||||||
|
let label = format!("kind=\"{kind}\"");
|
||||||
|
gauge(out, "ipx_worker_job_seconds", "How long the worker has been on its job, by the job's kind.",
|
||||||
|
&[(&label, since.elapsed().as_secs_f64())]);
|
||||||
|
}
|
||||||
|
let jobs = st.jobs.lock().unwrap().clone();
|
||||||
|
let labels: Vec<String> = jobs.keys().map(|k| format!("kind=\"{k}\"")).collect();
|
||||||
|
let pairs = |f: fn(&(u64, f64)) -> f64| labels.iter().zip(jobs.values()).map(|(l, v)| (l.as_str(), f(v))).collect::<Vec<_>>();
|
||||||
|
counter(out, "ipx_jobs_total", "Jobs the worker has finished, by kind.", &pairs(|v| v.0 as f64));
|
||||||
|
counter(out, "ipx_job_seconds_total", "Seconds the worker has spent on jobs, by kind.", &pairs(|v| v.1));
|
||||||
|
let last = st.last_scan_done.load(Relaxed);
|
||||||
|
if last > 0 {
|
||||||
|
gauge(out, "ipx_last_scan_age_seconds", "Seconds since a scan last finished.", &[("", (now - last) as f64)]);
|
||||||
|
}
|
||||||
|
gauge(out, "ipx_http_requests_in_flight", "Web requests being answered.", &[("", st.http_in_flight.load(Relaxed) as f64)]);
|
||||||
|
gauge(out, "ipx_scan_feeds_fetching", "Feeds a scan is fetching at once.", &[("", st.feeds_fetching.load(Relaxed) as f64)]);
|
||||||
|
gauge(out, "ipx_downloads_active", "Files downloading now.", &[("", st.downloads_active.load(Relaxed) as f64)]);
|
||||||
|
counter(out, "ipx_download_bytes_total", "Bytes downloaded since the daemon started.", &[("", st.download_bytes.load(Relaxed) as f64)]);
|
||||||
|
gauge(out, "ipx_event_queue", "Live updates sent and not yet read by every listener.", &[("", event_queue as f64)]);
|
||||||
|
counter(out, "ipx_events_missed_total", "Live updates dropped for a listener that fell behind.", &[("", st.events_missed.load(Relaxed) as f64)]);
|
||||||
|
gauge(out, "ipx_deadair_backlog", "Files of shows someone has DeadAir on that it has not looked at, those already heard through included.", &[("", st.deadair_backlog.load(Relaxed) as f64)]);
|
||||||
|
let since = st.deadair_busy_since.load(Relaxed);
|
||||||
|
gauge(out, "ipx_deadair_busy_seconds", "How long DeadAir has been on its file; 0 when on none.",
|
||||||
|
&[("", if since > 0 { (now - since) as f64 } else { 0.0 })]);
|
||||||
|
gauge(out, "ipx_feed_retries", "Feeds waiting to try a failed name lookup again.", &[("", retries as f64)]);
|
||||||
|
gauge(out, "ipx_art_cache_bytes", "Artwork kept on disk, as of the last sweep.", &[("", st.art_cache_bytes.load(Relaxed) as f64)]);
|
||||||
|
gauge(out, "ipx_art_cache_limit_bytes", "The most artwork kept.", &[("", art_limit as f64)]);
|
||||||
|
counter(out, "ipx_art_evicted_total", "Images thrown out to keep the artwork under its limit.", &[("", st.art_evicted.load(Relaxed) as f64)]);
|
||||||
|
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),
|
||||||
|
] {
|
||||||
|
let v = v.load(Relaxed);
|
||||||
|
if v >= 0 {
|
||||||
|
gauge(out, name, help, &[("", v as f64)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A counter, which only goes up while the daemon runs, in Prometheus's text format.
|
||||||
|
pub fn counter(out: &mut String, name: &str, help: &str, samples: &[(&str, f64)]) {
|
||||||
|
write(out, "counter", name, help, samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One gauge in Prometheus's text format: its help, its type, and a line for each set of labels
|
||||||
|
/// (`state="idle"`, or "" for none).
|
||||||
|
pub fn gauge(out: &mut String, name: &str, help: &str, samples: &[(&str, f64)]) {
|
||||||
|
write(out, "gauge", name, help, samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(out: &mut String, kind: &str, name: &str, help: &str, samples: &[(&str, f64)]) {
|
||||||
|
let _ = writeln!(out, "# HELP {name} {help}\n# TYPE {name} {kind}");
|
||||||
|
for (labels, value) in samples {
|
||||||
|
let labels = if labels.is_empty() { String::new() } else { format!("{{{labels}}}") };
|
||||||
|
let _ = writeln!(out, "{name}{labels} {value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gauges_are_written_as_prometheus_reads_them() {
|
||||||
|
let mut out = String::new();
|
||||||
|
gauge(&mut out, "ipx_db_connections", "Connections.", &[("state=\"idle\"", 3.0), ("state=\"in_use\"", 1.0)]);
|
||||||
|
gauge(&mut out, "ipx_feeds", "Feeds.", &[("", 1535.0)]);
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
"# HELP ipx_db_connections Connections.\n# TYPE ipx_db_connections gauge\n\
|
||||||
|
ipx_db_connections{state=\"idle\"} 3\nipx_db_connections{state=\"in_use\"} 1\n\
|
||||||
|
# HELP ipx_feeds Feeds.\n# TYPE ipx_feeds gauge\nipx_feeds 1535\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jobs_and_work_in_progress_are_counted_however_they_end() {
|
||||||
|
static T: Stats = Stats::new();
|
||||||
|
{
|
||||||
|
let _j = T.job("scan");
|
||||||
|
let _b = busy(&T.downloads_active);
|
||||||
|
assert_eq!(T.downloads_active.load(Relaxed), 1);
|
||||||
|
let mut out = String::new();
|
||||||
|
work(&mut out, &T, 0, 0, 0);
|
||||||
|
assert!(out.contains("ipx_worker_busy 1\n") && out.contains("ipx_worker_job_seconds{kind=\"scan\"}"), "{out}");
|
||||||
|
}
|
||||||
|
// Dropped, as an error or a stop drops them: nothing left in progress, one scan done.
|
||||||
|
assert_eq!(T.downloads_active.load(Relaxed), 0);
|
||||||
|
let mut out = String::new();
|
||||||
|
work(&mut out, &T, 3, 1, 10);
|
||||||
|
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.
|
||||||
|
assert!(!out.contains("ipx_feeds "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_process_reads_its_memory_and_files_on_linux() {
|
||||||
|
let (rss, fds) = process();
|
||||||
|
assert!(rss.unwrap() > 0.0 && fds.unwrap() > 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
//! Disk quota and age sweeps. Replaces iPXQuotaManager.py ("SmartSpace").
|
//! Disk quota and age sweeps. Replaces iPXQuotaManager.py ("SmartSpace").
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use tracing::Instrument;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::db::{Candidate, Db, now};
|
use crate::db::{Candidate, Db, now, step};
|
||||||
|
|
||||||
/// Headroom kept below the quota, as getToQuota()'s `1048576 * 50` did.
|
/// Headroom kept below the quota, as getToQuota()'s `1048576 * 50` did.
|
||||||
pub const PAD_BYTES: u64 = 50 * 1024 * 1024;
|
pub const PAD_BYTES: u64 = 50 * 1024 * 1024;
|
||||||
@@ -49,7 +50,8 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
|||||||
let mut report = Report::default();
|
let mut report = Report::default();
|
||||||
|
|
||||||
// Someone may have deleted a file by hand; the row must stop claiming it exists.
|
// Someone may have deleted a file by hand; the row must stop claiming it exists.
|
||||||
for (id, path) in db.missing_files().await? {
|
// Each step is a span of the reap's trace, so a slow one says which part was slow.
|
||||||
|
for (id, path) in db.missing_files().instrument(step("missing_files")).await? {
|
||||||
if !dry_run {
|
if !dry_run {
|
||||||
db.mark_reaped(id).await?;
|
db.mark_reaped(id).await?;
|
||||||
}
|
}
|
||||||
@@ -57,16 +59,20 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
|||||||
report.reconciled += 1;
|
report.reconciled += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let candidates = db.reap_candidates().await?;
|
for id in db.prune_abandoned_failures().instrument(step("prune_abandoned_failures")).await? {
|
||||||
|
tracing::info!(feed = id, "forgot a failing feed nobody subscribes to");
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidates = db.reap_candidates().instrument(step("reap_candidates")).await?;
|
||||||
|
|
||||||
if cfg.general.max_age_days > 0 {
|
if cfg.general.max_age_days > 0 {
|
||||||
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
|
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
|
||||||
report.aged_out = aged(&candidates, cutoff);
|
report.aged_out = aged(&candidates, cutoff);
|
||||||
for c in &report.aged_out {
|
for c in &report.aged_out {
|
||||||
report.bytes_freed += remove(db, c, dry_run).await?;
|
report.bytes_freed += remove(db, c, dry_run).instrument(step("remove_aged")).await?;
|
||||||
}
|
}
|
||||||
if !dry_run {
|
if !dry_run {
|
||||||
report.entries_pruned = db.prune_entries(cutoff).await?;
|
report.entries_pruned = db.prune_entries(cutoff).instrument(step("prune_entries")).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +87,7 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
|||||||
let total: u64 = remaining.iter().map(|c| c.bytes.max(0) as u64).sum();
|
let total: u64 = remaining.iter().map(|c| c.bytes.max(0) as u64).sum();
|
||||||
report.over_quota = pick(&remaining, total, limit);
|
report.over_quota = pick(&remaining, total, limit);
|
||||||
for c in &report.over_quota {
|
for c in &report.over_quota {
|
||||||
report.bytes_freed += remove(db, c, dry_run).await?;
|
report.bytes_freed += remove(db, c, dry_run).instrument(step("remove_over_quota")).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +98,13 @@ async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
|||||||
if dry_run {
|
if dry_run {
|
||||||
return Ok(c.bytes.max(0) as u64);
|
return Ok(c.bytes.max(0) as u64);
|
||||||
}
|
}
|
||||||
let size = std::fs::metadata(&c.path).map(|m| m.len()).unwrap_or(c.bytes.max(0) as u64);
|
// A torrent's folder measures as a directory entry, not what is in it: the recorded size then.
|
||||||
|
let size = std::fs::metadata(&c.path)
|
||||||
|
.ok()
|
||||||
|
.filter(|m| m.is_file())
|
||||||
|
.map_or(c.bytes.max(0) as u64, |m| m.len());
|
||||||
// Missing is fine: the point is that it is gone.
|
// Missing is fine: the point is that it is gone.
|
||||||
if let Err(e) = std::fs::remove_file(&c.path)
|
if let Err(e) = crate::download::remove(std::path::Path::new(&c.path))
|
||||||
&& e.kind() != std::io::ErrorKind::NotFound
|
&& e.kind() != std::io::ErrorKind::NotFound
|
||||||
{
|
{
|
||||||
tracing::warn!(path = c.path, error = %e, "could not delete");
|
tracing::warn!(path = c.path, error = %e, "could not delete");
|
||||||
@@ -185,11 +195,36 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_marks_of_someone_who_left_count_for_nothing() {
|
||||||
|
// ray and sam subscribe; left read one episode and pinned the others before unsubscribing.
|
||||||
|
let db = Db::memory().await.unwrap();
|
||||||
|
db.exec_for_test(
|
||||||
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false),(3,'left',false);
|
||||||
|
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
|
||||||
|
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||||
|
('f', 'heard', 0), ('f', 'pinned', 0), ('f', 'old', 0);
|
||||||
|
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
|
||||||
|
(3, 'f', 'heard', true, false),
|
||||||
|
(1, 'f', 'heard', true, false),
|
||||||
|
(3, 'f', 'pinned', false, true),
|
||||||
|
(3, 'f', 'old', false, true);
|
||||||
|
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
||||||
|
(1, 'f', 'heard', 'u1', '/tmp/heard', 10, 'done', 10),
|
||||||
|
(2, 'f', 'pinned', 'u2', '/tmp/pinned', 10, 'done', 20);",
|
||||||
|
).await
|
||||||
|
.unwrap();
|
||||||
|
let got: Vec<(i64, bool)> = db.reap_candidates().await.unwrap().iter().map(|c| (c.id, c.read)).collect();
|
||||||
|
assert_eq!(got, vec![(1, false), (2, false)], "sam has not heard it, and nobody here pinned it (#156)");
|
||||||
|
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "the old item goes, a leaver's pin or not");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn prune_keeps_entries_that_still_have_a_file() {
|
async fn prune_keeps_entries_that_still_have_a_file() {
|
||||||
let db = Db::memory().await.unwrap();
|
let db = Db::memory().await.unwrap();
|
||||||
db.exec_for_test(
|
db.exec_for_test(
|
||||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true);
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true);
|
||||||
|
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f');
|
||||||
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',true);
|
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',true);
|
||||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||||
('f', 'has-file', 100),
|
('f', 'has-file', 100),
|
||||||
@@ -1,27 +1,54 @@
|
|||||||
|
# A sample: ipx with its database in SQLite, and ipx-web, the page, in front of it. Build both
|
||||||
|
# images first (`docker build -t ipx .` and `docker build -f Dockerfile.web -t ipx-web .`), then
|
||||||
|
# `docker compose up -d`, and sign in at http://<host>:8099 as admin / ipodderx.
|
||||||
|
# docs/configuration.md has the rest, Postgres included.
|
||||||
services:
|
services:
|
||||||
ipx:
|
ipx:
|
||||||
image: 192.168.1.130:5000/ipx:latest
|
image: ipx
|
||||||
container_name: iPX
|
container_name: ipx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
# Who owns the files it writes. 99:100 is Unraid's nobody:users.
|
||||||
PUID: "99"
|
PUID: "99"
|
||||||
PGID: "100"
|
PGID: "100"
|
||||||
TZ: "America/Toronto"
|
TZ: "UTC"
|
||||||
IPX_LOG: "ipx=info"
|
# For Postgres instead of SQLite:
|
||||||
# IPX_DATABASE_URL=postgres://... to use Postgres; without it, /data/state.db (SQLite).
|
# IPX_DATABASE_URL: "postgres://ipx:password@db-host:5432/ipx"
|
||||||
env_file:
|
# No web port of its own: ipx-web is the way in, and passes the API on to it.
|
||||||
- ipx.env # relative: Arcane resolves it inside its own container
|
|
||||||
ports:
|
ports:
|
||||||
- "8099:8099" # web UI
|
|
||||||
- "6881:6881/tcp" # BitTorrent peers
|
- "6881:6881/tcp" # BitTorrent peers
|
||||||
- "6881:6881/udp" # DHT
|
- "6881:6881/udp" # DHT
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/fast/appdata/ipx:/config # config.toml, and the web token
|
- ./config:/config # config.toml, written on first start
|
||||||
- /mnt/user/ipx/:/data # state.db
|
- ./data:/data # state.db
|
||||||
- /mnt/user/ipx/downloads:/downloads
|
- ./downloads:/downloads
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "ipx", "status"]
|
test: ["CMD", "ipx", "status"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
ipx-web:
|
||||||
|
image: ipx-web
|
||||||
|
container_name: ipx-web
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
IPX_UPSTREAM: "ipx:8099"
|
||||||
|
# Behind Cloudflare Access only: where cloudflared's requests come from, the one address
|
||||||
|
# whose Cf-Access-* headers are passed on. docs/sso.md says how to find it.
|
||||||
|
# TUNNEL_PEER: "192.168.64.1"
|
||||||
|
# Traces to an OpenTelemetry collector, a span for each request, continued into ipx:
|
||||||
|
# WEB_TRACING: "on"
|
||||||
|
# OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector:4318"
|
||||||
|
# OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf"
|
||||||
|
# OTEL_SERVICE_NAME: "ipx-web"
|
||||||
|
ports:
|
||||||
|
- "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
|
||||||
|
|||||||
@@ -1,34 +1,61 @@
|
|||||||
# How it works
|
# How it works
|
||||||
|
|
||||||
One binary, `ipx`. `ipx daemon` runs three things in one process: a scheduler, a Unix-socket
|
Three services, each a container of its own with its own version and changelog (#207):
|
||||||
control server, and the web UI. Everything else is a CLI that either does the work itself or hands
|
|
||||||
it to a running daemon.
|
| Service | What it is | Source | Image |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ipx-web | Caddy: serves the page, a static site built from `web/`, and passes `/api` and `/media` to ipx on the same address. It asks ipx before it serves the app or the admin page (`/api/page-check`) | `web/`, `web/Caddyfile` | `Dockerfile.web` |
|
||||||
|
| ipx | The daemon and CLI, one binary. `ipx daemon` runs a scheduler, a Unix-socket control server and the HTTP API in one process; everything else is a CLI that either does the work itself or hands it to a running daemon | `crates/ipx` | `Dockerfile` |
|
||||||
|
| ipx-directory | The directory of public podcasts that ipx servers share. ipx asks it on its people's behalf; the page never talks to it ([directory.md](directory.md)) | `crates/directory` | `Dockerfile.directory` |
|
||||||
|
|
||||||
|
The Rust source is a Cargo workspace (#202); `crates/feed` (`ipx-feed`) fetches and parses feeds
|
||||||
|
for both Rust services. Versions: ipx's in `crates/ipx/Cargo.toml`, the directory's in
|
||||||
|
`crates/directory/Cargo.toml`, the page's in `package.json`; CLAUDE.md has the release steps.
|
||||||
|
|
||||||
|
## Traces and logs
|
||||||
|
|
||||||
|
A request is one trace (#213). The page starts one for each tap and sends `traceparent` with its
|
||||||
|
requests (#190); Caddy's span continues it and passes it on to ipx; ipx's request span, its steps
|
||||||
|
and its calls to the directory follow, and the directory's request span joins it. Scans,
|
||||||
|
downloads, DeadAir's work, shares with the directory and the directory's refresher rounds are
|
||||||
|
traces of their own. All three log one JSON object a line, for Loki; ipx's lines carry their trace
|
||||||
|
id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web] metrics_bind`,
|
||||||
|
#194). The exporter and the `traceparent` handling both Rust services use are in
|
||||||
|
`crates/directory/src/otel.rs`.
|
||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
| File | Responsibility | What it replaced in the Python |
|
| File | Responsibility | What it replaced in the Python |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `src/main.rs` | CLI, dispatch, scan loop, download policy | `iPXAgent.py` |
|
| `crates/ipx/src/main.rs` | CLI, dispatch, scan loop, download policy | `iPXAgent.py` |
|
||||||
| `src/config.rs` | TOML load/save, `General`/`Feed`/`Web`, intervals, slugs | `iPXSettings.py`, `feeds.plist` |
|
| `crates/ipx/src/config.rs` | TOML load/save, `General`/`Feed`/`Web`, intervals, slugs | `iPXSettings.py`, `feeds.plist` |
|
||||||
| `src/db.rs` | Every query, through SeaORM; creates missing tables | `.ipxd` plists, `history.dat`, `qmcache.dat` |
|
| `crates/ipx/src/db.rs` | Every query, through SeaORM; creates missing tables | `.ipxd` plists, `history.dat`, `qmcache.dat` |
|
||||||
| `src/entity.rs` | The tables, as SeaORM entities: the schema | — |
|
| `crates/ipx/src/entity.rs` | The tables, as SeaORM entities: the schema | — |
|
||||||
| `src/feed.rs` | Conditional GET, RSS/Atom/OPML parsing | `FeedData.__getFeed/__getEntries` |
|
| `crates/feed/src/lib.rs` | Conditional GET, RSS/Atom/OPML parsing, `url_looks_private` | `FeedData.__getFeed/__getEntries` |
|
||||||
| `src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` |
|
| `crates/ipx/src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` |
|
||||||
| `src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 |
|
| `crates/ipx/src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 |
|
||||||
| `src/retention.rs` | Quota and age sweeps | `iPXQuotaManager.py` |
|
| `crates/ipx/src/retention.rs` | Quota and age sweeps | `iPXQuotaManager.py` |
|
||||||
| `src/ipc.rs` | Event and command types, the socket server | `printMSG` on stdout |
|
| `crates/ipx/src/ipc.rs` | Event and command types, the socket server | `printMSG` on stdout |
|
||||||
| `src/auth.rs` | Argon2id hashing, session tokens, header names | — |
|
| `crates/ipx/src/auth.rs` | Argon2id hashing, session tokens, header names | — |
|
||||||
| `src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — |
|
| `crates/ipx/src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — |
|
||||||
| `src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
|
| `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/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 | — |
|
||||||
|
| `crates/directory/src/otel.rs` | Traces for both Rust services: the exporter, `traceparent` in and out | — |
|
||||||
| `web/index.html` | The app's markup | — |
|
| `web/index.html` | The app's markup | — |
|
||||||
| `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — |
|
| `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — |
|
||||||
| `web/app.css` | The stylesheet both pages share | — |
|
| `web/app.css` | The stylesheet both pages share | — |
|
||||||
| `web/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — |
|
| `web/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — |
|
||||||
| `web/build.mjs` | swc: strips the types into `app.js`/`login.js`, named in the page by a hash of their contents, and minifies | — |
|
| `web/build.mjs` | swc: strips the types into `app.js`/`admin.js`/`login.js`, named in the page by a hash of their contents, minifies, and writes the static site to `web/dist` | — |
|
||||||
| `build.rs` | Runs `web/build.mjs` into `OUT_DIR`, where `web.rs` `include_str!`s the result | — |
|
| `web/boot.js` | In `<head>`: the theme from its cookie, and an admin's link, before the first frame | — |
|
||||||
|
| `web/Caddyfile` | `ipx-web`: serves `web/dist`, asks ipx before serving a page, passes `/api` and `/media` to ipx | — |
|
||||||
|
|
||||||
The page is compiled in, so **editing `web/index.html` or `web/src` needs a rebuild**, and a
|
The page is a separate image, `ipx-web` (`Dockerfile.web`, #203), so **editing `web/` needs
|
||||||
build needs node and `npm ci` run once.
|
ipx-web rebuilt**, not ipx, and building it needs node and `npm ci` run once. ipx serves the API
|
||||||
|
and the files, and no page.
|
||||||
|
|
||||||
## A scan
|
## A scan
|
||||||
|
|
||||||
@@ -38,7 +65,9 @@ build needs node and `npm ci` run once.
|
|||||||
re-derived into the database each scan, never written to config.toml. A Patreon creator link
|
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
|
(a token, no `show=`) with more than one show is treated the same way, before any fetch: its
|
||||||
shows come from Patreon's web API and each becomes a derived feed.
|
shows come from Patreon's web API and each becomes a derived feed.
|
||||||
4. Record entries. A changed title or description flips the item back to unread.
|
4. Record entries: the new ones, and those the feed now gives a different title, text, artwork,
|
||||||
|
length or number, compared with what is stored for the items it lists (#141). A corrected item
|
||||||
|
keeps its read state.
|
||||||
5. Record enclosures. `enclosures.url` is `UNIQUE`, which is the dedupe key and subsumes the
|
5. Record enclosures. `enclosures.url` is `UNIQUE`, which is the dedupe key and subsumes the
|
||||||
original's `history.dat` pickle: a reaped file keeps its row so it is never fetched twice.
|
original's `history.dat` pickle: a reaped file keeps its row so it is never fetched twice.
|
||||||
6. Apply the merged policy (see [users.md](users.md)) and mark anything rejected as `skipped` with
|
6. Apply the merged policy (see [users.md](users.md)) and mark anything rejected as `skipped` with
|
||||||
@@ -51,8 +80,8 @@ build needs node and `npm ci` run once.
|
|||||||
## Data model
|
## Data model
|
||||||
|
|
||||||
```
|
```
|
||||||
feeds id, url, title, image, etag, last_modified, last_checked, ttl_mins,
|
feeds id, url, title, image, category, description, etag, last_modified,
|
||||||
last_error, orphaned, group_id, managed
|
last_checked, ttl_mins, last_error, orphaned, group_id, managed
|
||||||
entries feed_id, guid, title, link, published, description, first_seen,
|
entries feed_id, guid, title, link, published, description, first_seen,
|
||||||
image, duration, episode, season PK (feed_id, guid)
|
image, duration, episode, season PK (feed_id, guid)
|
||||||
enclosures id, feed_id, guid, url UNIQUE, mime, length, path, state,
|
enclosures id, feed_id, guid, url UNIQUE, mime, length, path, state,
|
||||||
@@ -68,10 +97,11 @@ entry_state user_id, feed_id, guid, read, flagged, position
|
|||||||
Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position` columns from
|
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.
|
before accounts; two bugs came from queries still reading them, and they were dropped in 0.5.
|
||||||
|
|
||||||
Schema changes: the tables are the entities in `src/entity.rs`, and `Db::open` creates whatever
|
Schema changes: the tables are the entities in `crates/ipx/src/entity.rs`, and `Db::open` creates whatever
|
||||||
table or index a database is missing from them (`db::create_missing`), with `IF NOT EXISTS`. It
|
table or index a database is missing from them (`db::create_missing`), with `IF NOT EXISTS`. It
|
||||||
never alters a table that exists, so a new column on one needs its own `ALTER` in
|
never alters a table that exists, so a new column on one needs its own `ALTER` in
|
||||||
`create_missing`, or `sea-orm-migration` once there are several. `Db::memory()` builds its
|
`create_missing`, looked for first and run only when it is missing, as `feeds.description`'s is,
|
||||||
|
or `sea-orm-migration` once there are several. `Db::memory()` builds its
|
||||||
database the same way, so the tests run on the schema production gets. A database from before
|
database the same way, so the tests run on the schema production gets. A database from before
|
||||||
0.7 takes its last columns from the old `migrate()`, so it upgrades through a 0.7 release first.
|
0.7 takes its last columns from the old `migrate()`, so it upgrades through a 0.7 release first.
|
||||||
|
|
||||||
@@ -89,13 +119,15 @@ printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Commands** — `fetch` (optional `feed`, `force`), `reap` (optional `dry_run`), `download`
|
**Commands** — `fetch` (optional `feed`, `force`), `reap` (optional `dry_run`), `download`
|
||||||
(`enclosure`), `status`.
|
(`enclosure`), `status`, `reload` (read the catalogue and settings again from the database, which
|
||||||
|
`ipx add`, `rm` and `import` send after changing them in a process of their own; answered with
|
||||||
|
`status`).
|
||||||
|
|
||||||
**Events** — `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `download_done`,
|
**Events** — `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `download_done`,
|
||||||
`download_error`, `torrent_deferred`, `reaped`, `reap_done`, `scan_done`, `status`, `error`.
|
`download_error`, `torrent_deferred`, `reaped`, `reap_done`, `scan_done`, `status`, `error`.
|
||||||
`scan_done`, `reap_done` and `status` are terminal: a client that asked for work stops reading
|
`scan_done`, `reap_done` and `status` are terminal: a client that asked for work stops reading
|
||||||
there. Commands run one at a time, in the order they arrive, except `status`: the socket answers it
|
there. Commands run one at a time, in the order they arrive, except `status` and `reload`: the
|
||||||
straight away, so the Docker healthcheck is never left waiting behind a scan or a download, and
|
socket answers them straight away, so the Docker healthcheck is never left waiting behind a scan or a download, and
|
||||||
answers only the client that asked, since `status` would end any other client's session.
|
answers only the client that asked, since `status` would end any other client's session.
|
||||||
|
|
||||||
Progress carries the enclosure id, without which a UI cannot tell one download from another and
|
Progress carries the enclosure id, without which a UI cannot tell one download from another and
|
||||||
@@ -110,12 +142,13 @@ so a long download had to be able to notice the signal itself.
|
|||||||
## HTTP API
|
## HTTP API
|
||||||
|
|
||||||
Everything below `/api` needs a signed-in user; the browser gets a redirect to `/login`, anything
|
Everything below `/api` needs a signed-in user; the browser gets a redirect to `/login`, anything
|
||||||
else a `401`.
|
else a `401`. A feed's items and files (its entries, `download-latest`, `/api/enclosures/{id}` and
|
||||||
|
`/media/{id}`) answer only someone who subscribes to the feed; anyone else gets a `404`.
|
||||||
|
|
||||||
| Route | |
|
| Route | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /` | the app |
|
| `GET /api/page-check`, `GET /api/login-check`, `GET /api/signin` | for Caddy (#203): whether the visitor may have the page in `X-Forwarded-Uri` (`204`), or where to send them; signing in with `?token=` |
|
||||||
| `GET /login`, `POST /api/login`, `POST /api/logout`, `GET /api/me` | sign-in |
|
| `POST /api/login`, `POST /api/logout`, `GET /api/me` | sign-in; `/api/me` also gives the version and the account's theme from before |
|
||||||
| `GET /api/feeds`, `POST /api/feeds` | your subscriptions; subscribe |
|
| `GET /api/feeds`, `POST /api/feeds` | your subscriptions; subscribe |
|
||||||
| `PATCH /api/feeds/{id}`, `DELETE /api/feeds/{id}` | your settings or (admin) the feed's; unsubscribe |
|
| `PATCH /api/feeds/{id}`, `DELETE /api/feeds/{id}` | your settings or (admin) the feed's; unsubscribe |
|
||||||
| `GET /api/feeds/{id}/entries` | paged, filtered, searchable, sortable (`sort` = kept, title, feed, type, size or published; `dir` = asc or desc) |
|
| `GET /api/feeds/{id}/entries` | paged, filtered, searchable, sortable (`sort` = kept, title, feed, type, size or published; `dir` = asc or desc) |
|
||||||
@@ -126,8 +159,12 @@ else a `401`.
|
|||||||
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
||||||
| `POST /api/fetch` | |
|
| `POST /api/fetch` | |
|
||||||
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
||||||
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with an OPML's feeds in place of the OPML and everyone counted (id, title, art, count, whether it is yours, the feed's iTunes category, whether it carries audio or video; never a URL, never a private feed); subscribe by id |
|
| `GET /api/directory/{id}` | a feed's page from the directory service (#205), by the directory's id: its description and latest twenty items, sanitized here, and why its last check failed, in words; never a file or its address |
|
||||||
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
|
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the directory service's ten most subscribed and every feed it lists, A to Z, asked by ipx with its token (directory id, title, art, subscribers across every sharing server, whether it is yours and its id here, category, whether it carries audio or video; never a URL); subscribe by the directory's id, which takes the feed's address from the directory. `503` with no directory set up |
|
||||||
|
| `DELETE /api/directory/{id}`, `POST /api/directory/{id}/refresh` | admin-only, and the directory refuses unless this server's token is `rwa` (#206): remove and block a feed; check it now. `GET /api/directory/{id}` says `can_admin` when both hold |
|
||||||
|
| `GET /api/directory-admin`, `DELETE /api/directory-blocked/{id}`, `DELETE /api/directory-instances/{name}` | admin-only, `rwa` (#206): the servers sharing (name, feeds, subscribers) and the blocked feeds (id, title, when; never the address, often why it was blocked); unblock; drop a server's snapshot. Unblocking or dropping sends this server's snapshot again at once |
|
||||||
|
| `GET /api/directory-status` | admin-only: the name and access the directory gives this server's token, or why it refused, and how the last snapshot went |
|
||||||
|
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write. The directory's token is written, never read back: `directory_token_set` says whether there is one |
|
||||||
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
|
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
|
||||||
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
||||||
| `GET /api/logs` | admin-only; the ring buffer, with a sequence cursor |
|
| `GET /api/logs` | admin-only; the ring buffer, with a sequence cursor |
|
||||||
@@ -143,6 +180,7 @@ cargo test # parsing, filters, retention, schedules, SQL, per-use
|
|||||||
node tests/page-smoke.js # the page script loads and every selector it wires at load exists
|
node tests/page-smoke.js # the page script loads and every selector it wires at load exists
|
||||||
node tests/native-bridge.js # the page hands playback to a native shell
|
node tests/native-bridge.js # the page hands playback to a native shell
|
||||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||||
|
node tests/load/run.js # k6: many people at once against a daemon with 1,500 generated feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
The Rust tests cannot see a wrong selector, a handler that runs and does nothing, or a page that
|
The Rust tests cannot see a wrong selector, a handler that runs and does nothing, or a page that
|
||||||
|
|||||||
10
docs/cli.md
@@ -13,12 +13,16 @@ processes must never download the same thing. `--local` forces the work to happe
|
|||||||
| `ipx list` | Subscriptions and their state |
|
| `ipx list` | Subscriptions and their state |
|
||||||
| `ipx status` | Counts: feeds, pending, downloaded |
|
| `ipx status` | Counts: feeds, pending, downloaded |
|
||||||
| `ipx fetch [FEED] [--force]` | Scan everything, or one feed. `--force` ignores the TTL |
|
| `ipx fetch [FEED] [--force]` | Scan everything, or one feed. `--force` ignores the TTL |
|
||||||
| `ipx add <url> [--folder X] [--keywords a,b]` | Subscribe; the id comes from the feed title |
|
| `ipx add <url> [--folder X] [--keywords a,b] [--list] [--category C]` | Subscribe; the id comes from the feed title. `--list` subscribes no one but keeps it in the catalogue, and so in what this server shares with the directory, when its last subscriber leaves; `--category` is the category the directory files it under when the feed names none. Run for a feed already in the catalogue, these list it or set its category |
|
||||||
| `ipx rm <feed>` | Unsubscribe; downloads and history are kept |
|
| `ipx rm <feed>` | Unsubscribe; downloads and history are kept |
|
||||||
| `ipx import <file.opml>` / `ipx export <file.opml>` | Move subscriptions in or out. Import subscribes the first admin, as the shared web token does; in the web UI it subscribes whoever is signed in |
|
| `ipx import <file.opml>` / `ipx export <file.opml>` | Move subscriptions in or out. Import subscribes the first admin, as the shared web token does; in the web UI it subscribes whoever is signed in |
|
||||||
| `ipx reap [--dry-run]` | Run retention now |
|
| `ipx reap [--dry-run]` | Run retention now |
|
||||||
| `ipx user <add\|list\|passwd\|rm>` | Accounts for the web UI |
|
| `ipx user <add\|list\|passwd\|rm>` | Accounts for the web UI |
|
||||||
| `ipx daemon [--web ADDR]` | Scheduler, control socket and web UI |
|
| `ipx daemon [--web ADDR]` | Scheduler, control socket and the HTTP API (the page is ipx-web's) |
|
||||||
|
|
||||||
|
`add`, `rm` and `import` change the catalogue in a process of their own. With a daemon running they
|
||||||
|
then tell it to read the catalogue again; without that it kept its own copy and wrote it back at
|
||||||
|
its next change, undoing them. If it does not answer they say so: restart it.
|
||||||
|
|
||||||
## Accounts
|
## Accounts
|
||||||
|
|
||||||
@@ -67,7 +71,7 @@ stops the next scan fetching it again.
|
|||||||
## The daemon
|
## The daemon
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
ipx daemon # scheduler + socket + web UI
|
ipx daemon # scheduler + socket + HTTP API
|
||||||
ipx daemon --web 0.0.0.0:8099 # override the configured bind for one run
|
ipx daemon --web 0.0.0.0:8099 # override the configured bind for one run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Two places. **config.toml** holds what ipx needs before it reaches its database,
|
|||||||
who gets in: where things are (`download_dir`, `socket`, `organize`), `[torrent]` and `[web]`.
|
who gets in: where things are (`download_dir`, `socket`, `organize`), `[torrent]` and `[web]`.
|
||||||
**The database** holds the catalogue of feeds (`[feeds.<id>]` below) and the server settings the
|
**The database** holds the catalogue of feeds (`[feeds.<id>]` below) and the server settings the
|
||||||
admin page edits (`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`,
|
admin page edits (`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`,
|
||||||
`media_types`). Change those in the web UI, or with `ipx add`, `ipx rm` and `ipx import`; they
|
`media_types`, `art_cache_mb`). Change those in the web UI, or with `ipx add`, `ipx rm` and `ipx import`; they
|
||||||
take effect without a restart.
|
take effect without a restart.
|
||||||
|
|
||||||
The first time ipx meets a database that holds no catalogue, it takes the feeds and those
|
The first time ipx meets a database that holds no catalogue, it takes the feeds and those
|
||||||
@@ -25,8 +25,13 @@ config.toml's default location is `$XDG_CONFIG_HOME/ipx/config.toml`
|
|||||||
|
|
||||||
`~` is expanded in paths. The database is SQLite in WAL mode unless `IPX_DATABASE_URL` names a
|
`~` is expanded in paths. The database is SQLite in WAL mode unless `IPX_DATABASE_URL` names a
|
||||||
Postgres database instead. Back SQLite up by copying `state.db` while the daemon is stopped, or
|
Postgres database instead. Back SQLite up by copying `state.db` while the daemon is stopped, or
|
||||||
with `sqlite3 state.db .backup`; back Postgres up with `pg_dump`. `ipx copy-db <state.db>` copies a
|
with `sqlite3 state.db .backup`; back Postgres up with `pg_dump`.
|
||||||
SQLite database into the empty Postgres one `IPX_DATABASE_URL` names.
|
|
||||||
|
ipx does not wait for the disk to confirm each write: SQLite syncs at checkpoints
|
||||||
|
(`synchronous=NORMAL`), and ipx's own Postgres sessions set `synchronous_commit=off`, which leaves
|
||||||
|
other databases on the server as they are. Waiting made a scan take most of a second a feed. A
|
||||||
|
crash of ipx loses nothing; a power cut, or a crash of the Postgres server, can lose the last moment
|
||||||
|
of changes, a read mark or a saved position, and never corrupts the database.
|
||||||
|
|
||||||
## `[general]`
|
## `[general]`
|
||||||
|
|
||||||
@@ -40,21 +45,31 @@ max_total_gb = 50 # 0 = unlimited
|
|||||||
max_age_days = 30 # 0 = keep forever
|
max_age_days = 30 # 0 = keep forever
|
||||||
max_new_per_check = 3 # per feed, per scan. 0 = unlimited
|
max_new_per_check = 3 # per feed, per scan. 0 = unlimited
|
||||||
media_types = ["audio", "video"]
|
media_types = ["audio", "video"]
|
||||||
|
art_cache_mb = 500 # artwork kept on disk. 0 = none
|
||||||
```
|
```
|
||||||
|
|
||||||
`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check` and `media_types` move into the
|
`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`, `media_types` and `art_cache_mb` move into the
|
||||||
database as described above; `download_dir`, `socket` and `organize` stay in config.toml.
|
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
|
* **`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. Admin-only from the UI.
|
be polled *less* often, and a per-feed `schedule` overrides both. 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.
|
||||||
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
|
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
|
||||||
* **`max_total_gb`** — the reaper deletes to get back under this, oldest first, keeping a 50 MB
|
* **`max_total_gb`** — the reaper deletes to get back under this, oldest first, keeping a 50 MB
|
||||||
pad. Kept items are never deleted, and a file only counts as read once every subscriber has
|
pad. Kept items are never deleted, and a file only counts as read once every subscriber has
|
||||||
read it. `0` disables it entirely.
|
read it. `0` disables it entirely.
|
||||||
* **`max_age_days`** — items older than this with no file on disk are pruned from the database.
|
* **`max_age_days`** — items older than this with no file on disk are pruned from the database.
|
||||||
Kept ones stay. `0` disables it.
|
Kept ones stay. `0` disables it.
|
||||||
* **`max_new_per_check`** — the cap that stops a new subscription pulling a whole back catalogue.
|
* **`art_cache_mb`** — how much show and episode artwork iPX keeps on disk, in `art/` beside the
|
||||||
`0` means unlimited, which is rarely what you want: subscribing to an OPML of 80 feeds with no cap
|
database, so the page loads it from iPX rather than from every publisher's server. Over this,
|
||||||
|
what has gone longest unshown goes first. `0` keeps none: artwork still comes through iPX, fetched
|
||||||
|
each time. 500 by default.
|
||||||
|
* **`max_new_per_check`** — how many of a feed's newest episodes are downloaded; older ones stay
|
||||||
|
listed to download by hand. It stops a new subscription pulling a whole back catalogue.
|
||||||
|
`0` means every episode, for an archive; set it on the feeds you want archived, since on the
|
||||||
|
global default it applies to every feed: subscribing to an OPML of 80 feeds with no limit
|
||||||
fetched 216 files and 22 GB in one scan.
|
fetched 216 files and 22 GB in one scan.
|
||||||
* **`media_types`** — top-level MIME types taken automatically. Anything else is still listed and
|
* **`media_types`** — top-level MIME types taken automatically. Anything else is still listed and
|
||||||
can be fetched by hand; blog feeds put each article's header image in an `<enclosure>`, and
|
can be fetched by hand; blog feeds put each article's header image in an `<enclosure>`, and
|
||||||
@@ -88,6 +103,7 @@ access_aud = "" # the Access application's AUD tag
|
|||||||
auto_create_users = true
|
auto_create_users = true
|
||||||
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
||||||
session_days = 30
|
session_days = 30
|
||||||
|
metrics_bind = "" # e.g. "0.0.0.0:9187"; empty serves no metrics
|
||||||
```
|
```
|
||||||
|
|
||||||
* **`token`** — the shared secret, which signs in as the **admin**. `?token=…` sets a cookie, so
|
* **`token`** — the shared secret, which signs in as the **admin**. `?token=…` sets a cookie, so
|
||||||
@@ -104,6 +120,9 @@ session_days = 30
|
|||||||
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
|
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
|
||||||
the proxy signs them straight back in.
|
the proxy signs them straight back in.
|
||||||
* **`session_days`** — sign a session out after this long without a request.
|
* **`session_days`** — sign a session out after this long without a request.
|
||||||
|
* **`metrics_bind`** — where to serve `/metrics` for Prometheus: database connections in use, the
|
||||||
|
worker's queue, the runtime's tasks, memory and open files. It has a thread and a port of its own
|
||||||
|
and no sign-in, so publish it to the LAN only. Empty, the default, serves none.
|
||||||
|
|
||||||
It is plain HTTP. On a LAN bind everything crosses the network in the clear — and a feed URL can
|
It is plain HTTP. On a LAN bind everything crosses the network in the clear — and a feed URL can
|
||||||
itself carry a credential. Put TLS in front of it if that matters.
|
itself carry a credential. Put TLS in front of it if that matters.
|
||||||
@@ -121,6 +140,7 @@ folder = "Accidental Tech Podcast" # default: the feed title
|
|||||||
schedule = "every 6h" # overrides [general] for this feed
|
schedule = "every 6h" # overrides [general] for this feed
|
||||||
media_types = ["audio"] # overrides [general] for this feed
|
media_types = ["audio"] # overrides [general] for this feed
|
||||||
category = "Technology" # the Directory's, if the feed names none
|
category = "Technology" # the Directory's, if the feed names none
|
||||||
|
listed = true # shared with the directory with no subscribers
|
||||||
username = "ray" # HTTP basic auth
|
username = "ray" # HTTP basic auth
|
||||||
password_env = "IPX_ATP_PASS" # preferred over a literal `password`
|
password_env = "IPX_ATP_PASS" # preferred over a literal `password`
|
||||||
```
|
```
|
||||||
@@ -130,6 +150,17 @@ With more than one account, **`keywords`, `auto_download`, `allow_explicit` and
|
|||||||
config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
|
config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
|
||||||
and are the same for everyone. See [users.md](users.md).
|
and are the same for everyone. See [users.md](users.md).
|
||||||
|
|
||||||
|
**The Directory** is the directory service's ([directory.md](directory.md)), which an admin sets
|
||||||
|
up in Admin, Directory: its address, this server's token and whether to share. They are server
|
||||||
|
settings, kept in the database (`directory_url`, `directory_token`, `directory_share`), not here.
|
||||||
|
With sharing on, this server sends it every feed in the catalogue that is not private, with how
|
||||||
|
many people here subscribe, and the `category` above for a feed that names none. To offer one with
|
||||||
|
nobody subscribed, `ipx add --list --category News <url>`: it subscribes no one, and a listed feed
|
||||||
|
stays in the catalogue, and so in what is shared, when its last subscriber leaves, where another
|
||||||
|
is dropped. A feed nobody
|
||||||
|
subscribes to is checked once a day. One that is dead (failing for 30 days) or quiet (nothing
|
||||||
|
new in a year), with nobody subscribed and no file on disk, is removed from the catalogue.
|
||||||
|
|
||||||
Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth
|
Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth
|
||||||
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry.
|
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry.
|
||||||
|
|
||||||
@@ -142,5 +173,21 @@ and they are re-derived on every scan. Editing one in the UI promotes it to a ca
|
|||||||
| `IPX_DATABASE_URL` | A `postgres://user:password@host:port/database` URL: use that database instead of `state.db` |
|
| `IPX_DATABASE_URL` | A `postgres://user:password@host:port/database` URL: use that database instead of `state.db` |
|
||||||
| `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own |
|
| `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own |
|
||||||
| `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) |
|
| `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) |
|
||||||
|
| `IPX_LOG_FORMAT` | `json` for one JSON object a line, with each request's and event's fields as its own (for Loki and the like); text otherwise |
|
||||||
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
|
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
|
||||||
|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | An OTLP/HTTP collector, such as Tempo at `http://host:4318`: the daemon sends it traces of scans, downloads and web requests. The other `OTEL_EXPORTER_OTLP_*` variables apply too |
|
||||||
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |
|
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |
|
||||||
|
|
||||||
|
## ipx-web
|
||||||
|
|
||||||
|
The page's container reads only its environment (`web/Caddyfile`):
|
||||||
|
|
||||||
|
| Variable | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `IPX_UPSTREAM` | Where ipx is, `host:port`; `ipx:8099` by default |
|
||||||
|
| `WEB_BIND` | Where to listen; `:8080` by default |
|
||||||
|
| `WEB_ROOT` | The built page; `/srv` in the image |
|
||||||
|
| `TUNNEL_PEER` | The one address whose `Cf-Access-*` headers are passed on to ipx: where cloudflared's requests come from. Unset passes none. See [sso.md](sso.md) |
|
||||||
|
| `WEB_TRACING` | `on` sends a span for each request to the collector the `OTEL_*` variables name (`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf`, `OTEL_SERVICE_NAME`), continuing the page's trace into ipx. Off by default |
|
||||||
|
|
||||||
|
ipx-directory's settings are in [directory.md](directory.md).
|
||||||
|
|||||||
146
docs/directory.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# The directory
|
||||||
|
|
||||||
|
`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,
|
||||||
|
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.
|
||||||
|
|
||||||
|
This server's is at `https://directory.ipxreader.app` (until 2026-10-12 `ipx-dir.sdf1.net`, #214), the `ipx-directory` service of the Arcane project
|
||||||
|
`ipx`, with its database `ipx_directory` on the `databases` project's Postgres.
|
||||||
|
|
||||||
|
## In ipx
|
||||||
|
|
||||||
|
An admin sets it up in **Admin, Directory**: the directory's address, the token its operator gave
|
||||||
|
this server, and whether to share. ipx asks the directory on its people's behalf, so the page never
|
||||||
|
talks to it and never sees the token or a feed's address. With no directory set up, the Directory
|
||||||
|
says so and feeds are added by their address.
|
||||||
|
|
||||||
|
ipx keeps the address and token with its other server settings, in its database (`settings`,
|
||||||
|
`directory_url` and `directory_token`), not in config.toml. The token is written, never shown
|
||||||
|
again: the tab says it is set and offers to clear it. To replace one, make a new token on the
|
||||||
|
directory, paste it over the old, then delete the old token's entry from the directory's config.
|
||||||
|
|
||||||
|
With sharing on, ipx sends its snapshot when what it would send has changed (checked after every
|
||||||
|
scan, and whenever the catalogue or these settings change), and every half hour regardless (#211): each feed checked at least once, with how many people here subscribe,
|
||||||
|
and the category an admin gave a feed that names none. It never sends an OPML or a Patreon creator
|
||||||
|
itself (their feeds go instead), or a private feed. A feed's settings have a **Private** switch for
|
||||||
|
a paid feed iPX does not recognise by its address; anyone subscribed may turn it on, and only an
|
||||||
|
admin off.
|
||||||
|
|
||||||
|
When this server's token is `rwa`, its admins administer the directory from iPX (#206). A feed's
|
||||||
|
page in the Directory has **Check now** and **Remove from the directory**, which blocks it so no
|
||||||
|
server's snapshot brings it back. Admin, Directory lists the **Servers** sharing, by token name,
|
||||||
|
with how many feeds and subscribers each gave, and **Blocked**, by title, with Unblock. Dropping a
|
||||||
|
server's snapshot takes its feeds and counts out until it next shares; to keep a server out,
|
||||||
|
revoke its token. An admin's own subscriptions open as their feeds, not as Directory pages, so
|
||||||
|
removing one of those is done by marking it private, or from another admin's account.
|
||||||
|
|
||||||
|
## In a web browser
|
||||||
|
|
||||||
|
Anyone may browse it at its address, no token and no sign-in (#215): the most subscribed, the
|
||||||
|
categories, every feed A to Z, a search by name, and a page for each feed with its description,
|
||||||
|
its latest items and its address, with a Copy button, to subscribe to in any podcast app or feed
|
||||||
|
reader. The pages only read: nothing in them changes the directory. They are rendered by the
|
||||||
|
directory itself from what the API serves, with the publisher's HTML shown as text, an item's link
|
||||||
|
only when it is http(s), and a Content-Security-Policy that allows the site's own script and
|
||||||
|
stylesheet and the covers, which load from their publishers. `/v1` is the API and still needs a
|
||||||
|
token for everything.
|
||||||
|
|
||||||
|
## Tokens
|
||||||
|
|
||||||
|
Everything but `/healthz` needs a token, sent as `Authorization: Bearer <token>`. Each one has a
|
||||||
|
name and one of three levels of access:
|
||||||
|
|
||||||
|
| | browse | send snapshots | remove, block, refresh feeds; drop a server's snapshot |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ro` | yes | | |
|
||||||
|
| `rw` | yes | yes | |
|
||||||
|
| `rwa` | yes | yes | yes |
|
||||||
|
|
||||||
|
Tokens are in the config file and nowhere else, and no level of access can make or revoke one
|
||||||
|
over the API. To make one:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec ipx-directory ipx-directory token new <name> --access ro|rw|rwa
|
||||||
|
```
|
||||||
|
|
||||||
|
It prints the token, to give to whoever it is for, and the lines to add to the config file. The file
|
||||||
|
holds the token's SHA-256, never the token. The directory reads the file again within a minute of
|
||||||
|
a change, so adding a token and revoking one (deleting its entry) need no restart. A revoked token
|
||||||
|
also takes the counts its server sent. A file that does not load (a misspelled `access`, two tokens
|
||||||
|
with one name) leaves the tokens as they were, and the log says why.
|
||||||
|
|
||||||
|
## The config file
|
||||||
|
|
||||||
|
`/config/directory.toml` in the container (`DIRECTORY_CONFIG`, or `--config`), on Tower
|
||||||
|
`/mnt/fast/appdata/ipx-directory/directory.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
bind = "0.0.0.0:8100"
|
||||||
|
# The environment variable holding the database URL: postgres://... or sqlite://...
|
||||||
|
database_url_env = "DIRECTORY_DATABASE_URL"
|
||||||
|
|
||||||
|
[[tokens]]
|
||||||
|
name = "tower"
|
||||||
|
access = "rwa"
|
||||||
|
token_sha256 = "…"
|
||||||
|
```
|
||||||
|
|
||||||
|
`DIRECTORY_LOG_FORMAT=json` logs one JSON object a line, for Loki, as ipx's `IPX_LOG_FORMAT` does.
|
||||||
|
Each request is a line with its `method`, `path`, `status`, `duration_ms` and the `token`'s name.
|
||||||
|
|
||||||
|
With `OTEL_EXPORTER_OTLP_ENDPOINT` set (`http://host:4318`, OTLP over HTTP), it sends traces: a
|
||||||
|
span a request, a child of the caller's when the request carries a `traceparent`, as ipx's do, and
|
||||||
|
a trace a refresher round with a span a feed (#213). `OTEL_RESOURCE_ATTRIBUTES` tags them, as
|
||||||
|
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`.
|
||||||
|
|
||||||
|
## Running one
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
ipx-directory:
|
||||||
|
image: ipx-directory # docker build -f Dockerfile.directory -t 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
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "ipx-directory", "health"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## What it will not hold
|
||||||
|
|
||||||
|
A private feed is never meant to reach it. ipx leaves out a feed marked private, one with a
|
||||||
|
username or password, and one whose address `ipx_feed::url_looks_private` catches: a paid-feed
|
||||||
|
host, or a key in the query. The directory checks every address it is sent with the same function
|
||||||
|
and refuses what it catches, so one server's miss does not publish someone's paid feed to every
|
||||||
|
other. What both miss, an `rwa` token can remove: `DELETE /v1/feeds/{id}` removes the feed and
|
||||||
|
blocks it, so no snapshot brings it back.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Route | Access | |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /healthz` | none | `ok` when it can read its database |
|
||||||
|
| `GET /v1/me` | ro | the token's `name` and `access` |
|
||||||
|
| `GET /v1/feeds` | ro | every feed A to Z: `id`, `url`, `title`, `image`, `category`, `subcategory`, `podcast`, `subscribers` |
|
||||||
|
| `GET /v1/popular?limit=10` | ro | the most subscribed, at most 100 |
|
||||||
|
| `GET /v1/feeds/{id}` | ro | one feed, with `description`, `failing` (why its last check failed, in words) and its latest twenty `items` (`guid`, `title`, `link`, `published`, `duration`, `description`), never their files |
|
||||||
|
| `POST /v1/snapshot` | rw | `[{"url", "subscribers"}]`, at most 20,000: replaces everything this token sent before; answers what it `accepted` and how many it refused as private, blocked or invalid |
|
||||||
|
| `DELETE /v1/feeds/{id}` | rwa | remove and block |
|
||||||
|
| `GET /v1/blocked`, `DELETE /v1/blocked/{id}` | rwa | what is blocked; unblock |
|
||||||
|
| `GET /v1/instances` | rwa | each token holding a snapshot: its `name`, `feeds` and `subscribers` |
|
||||||
|
| `DELETE /v1/instances/{name}` | rwa | drop one token's snapshot, its counts with it |
|
||||||
|
| `POST /v1/feeds/{id}/refresh` | rwa | fetch the feed now, answering with its page |
|
||||||
|
|
||||||
|
A feed's `id` is the first 64 bits of the SHA-256 of its address, normalised (scheme and host in
|
||||||
|
lower case, no default port or fragment), so every server arrives at the same id. `subscribers`
|
||||||
|
is the sum over every token's latest snapshot. A feed is listed once the directory has read it as
|
||||||
|
a feed, so an OPML or an address that never worked is not, and goes when no snapshot has it any more. Descriptions
|
||||||
|
are the feed's own HTML, unsanitized: whoever shows them sanitizes them, as ipx does.
|
||||||
62
docs/sso.md
@@ -18,7 +18,7 @@ real setup and pointed at the wrong address.
|
|||||||
```
|
```
|
||||||
browser ─► Cloudflare Access, app "ipodderx" ─── sign in ───► Authentik (OpenID Connect)
|
browser ─► Cloudflare Access, app "ipodderx" ─── sign in ───► Authentik (OpenID Connect)
|
||||||
─► tunnel "rays-unraid" (the cloudflared container on Tower)
|
─► tunnel "rays-unraid" (the cloudflared container on Tower)
|
||||||
─► http://192.168.1.130:8099 ─► ipx
|
─► http://192.168.1.130:8099, ipx-web (Caddy) ─► ipx
|
||||||
```
|
```
|
||||||
|
|
||||||
Authentik is not in the request path. It is the identity provider Cloudflare Access asks. Access
|
Authentik is not in the request path. It is the identity provider Cloudflare Access asks. Access
|
||||||
@@ -38,7 +38,7 @@ request it forwards through the tunnel, and ipx signs that person in.
|
|||||||
enabled = true
|
enabled = true
|
||||||
bind = "0.0.0.0:8099"
|
bind = "0.0.0.0:8099"
|
||||||
trusted_header = "Cf-Access-Authenticated-User-Email"
|
trusted_header = "Cf-Access-Authenticated-User-Email"
|
||||||
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
|
trusted_proxies = ["127.0.0.1", "::1", "192.168.64.10"] # ipx-web; see "Behind ipx-web"
|
||||||
access_team = "rays-sdf1.cloudflareaccess.com"
|
access_team = "rays-sdf1.cloudflareaccess.com"
|
||||||
access_aud = "8bfe73dfbc8c548d1cb5dc11c6db6887bcaf4f5144840396f83a620a140e1c4f"
|
access_aud = "8bfe73dfbc8c548d1cb5dc11c6db6887bcaf4f5144840396f83a620a140e1c4f"
|
||||||
auto_create_users = true
|
auto_create_users = true
|
||||||
@@ -50,7 +50,7 @@ The last two turn on the token check described under [Verifying Cloudflare's tok
|
|||||||
on since 2026-09-19. Both can be read without the dashboard: a request to the site while signed
|
on since 2026-09-19. Both can be read without the dashboard: a request to the site while signed
|
||||||
out is sent to `https://<team domain>/cdn-cgi/access/login/ipodderx.sdf1.net?kid=<AUD tag>&...`.
|
out is sent to `https://<team domain>/cdn-cgi/access/login/ipodderx.sdf1.net?kid=<AUD tag>&...`.
|
||||||
|
|
||||||
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
|
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml
|
||||||
restart ipx`.
|
restart ipx`.
|
||||||
|
|
||||||
### What was missing
|
### What was missing
|
||||||
@@ -65,22 +65,34 @@ Cloudflare and Authentik were already right. Three things on the ipx side were n
|
|||||||
`rays@sdf1.net`. With `auto_create_users` on, the first visit would have made a second, empty
|
`rays@sdf1.net`. With `auto_create_users` on, the first visit would have made a second, empty
|
||||||
account. `ipx user rename rays rays@sdf1.net` fixed that without losing anything.
|
account. `ipx user rename rays rays@sdf1.net` fixed that without losing anything.
|
||||||
|
|
||||||
### The address to trust, and why it is 192.168.16.1
|
### The address to trust, and why it is 192.168.64.1
|
||||||
|
|
||||||
`cloudflared` runs in its own container and reaches ipx through the host's published port. Docker
|
`cloudflared` runs in its own container and reaches ipx-web (before #203, ipx) through the host's published port. Docker
|
||||||
(iptables firewall backend) masquerades traffic between its bridge networks, so the tunnel's
|
(iptables firewall backend) masquerades traffic between its bridge networks, so the tunnel's
|
||||||
requests arrive from the **gateway of ipx's own network**, `content_default`:
|
requests arrive from the **gateway of ipx's own network**, `ipx_default`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker network inspect content_default -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
|
docker network inspect ipx_default -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
That was measured, not assumed. ipx does not log where a request came from, so the addresses were
|
That was measured, not assumed. ipx does not log where a request came from, so the addresses were
|
||||||
read from the kernel's connection table inside the container while the site was open. (`/proc/net/tcp`
|
read from the kernel's connection table inside the container while the site was open. (`/proc/net/tcp`
|
||||||
lists them in hex.)
|
lists them in hex.)
|
||||||
|
|
||||||
If the `content` project's network is ever recreated, its gateway can change. Check it again, and
|
The `ipx` project's compose file pins that network's subnet to `192.168.64.0/20`, so its gateway
|
||||||
update `trusted_proxies` to match.
|
stays `192.168.64.1` when the network is recreated. It was `192.168.16.1`, `content_default`'s, while
|
||||||
|
ipx was part of the `content` project. If the subnet is ever changed, update `TUNNEL_PEER` in the
|
||||||
|
compose file to match (before #203, `trusted_proxies`).
|
||||||
|
|
||||||
|
### Behind ipx-web (#203)
|
||||||
|
|
||||||
|
With the page served by `ipx-web` (Caddy), cloudflared reaches Caddy instead, still through the
|
||||||
|
host's port 8099, so its requests still arrive from `192.168.64.1`, at Caddy now. Caddy passes the
|
||||||
|
`Cf-Access-*` headers on only from that address (`TUNNEL_PEER`) and strips them from everyone else,
|
||||||
|
the boundary `trusted_proxies` drew before. ipx then sees every request come from Caddy, so
|
||||||
|
`trusted_proxies` names Caddy's address on `ipx_default`, pinned in the compose file
|
||||||
|
(`192.168.64.10`), instead of the gateway. The token check is unchanged: a header Caddy passes on
|
||||||
|
still needs a valid `Cf-Access-Jwt-Assertion`.
|
||||||
|
|
||||||
### Names
|
### Names
|
||||||
|
|
||||||
@@ -111,9 +123,13 @@ feeds.
|
|||||||
|
|
||||||
Authentik's library lists Authentik's own applications, and ipx signs in through the one
|
Authentik's library lists Authentik's own applications, and ipx signs in through the one
|
||||||
called `Cloudflare Access`, so ipx needs a bookmark of its own to show up there. It is
|
called `Cloudflare Access`, so ipx needs a bookmark of its own to show up there. It is
|
||||||
Applications → Applications → `ipodderx`: no provider, launch URL `https://ipodderx.sdf1.net`, and
|
Applications → Applications → `iPX` (slug `ipodderx`): no provider, launch URL
|
||||||
the ipx logo. Like Outline's, it has no policy bindings, so everyone in Authentik sees the
|
`https://ipodderx.sdf1.net`, and web/logo.svg at 512px as its icon, uploaded again when the logo
|
||||||
tile. Who actually gets in is still up to the Access policy.
|
changes. The library keeps each person's list of tiles in a cache that an edit to the application
|
||||||
|
does not clear, so after one, clear it (System → Policies → Clear cache, or
|
||||||
|
`POST /api/v3/policies/all/cache_clear/`) or the old name and icon stay up. It has no policy
|
||||||
|
bindings, so everyone in Authentik sees the tile. Who actually gets in is still up to the Access
|
||||||
|
policy.
|
||||||
|
|
||||||
### Check it
|
### Check it
|
||||||
|
|
||||||
@@ -169,10 +185,12 @@ itself, with a **Proxy Provider** and an **outpost** that adds `X-authentik-user
|
|||||||
|
|
||||||
## How this is secured
|
## How this is secured
|
||||||
|
|
||||||
**The header is only believed from `trusted_proxies`.** Every other source is ignored, and the
|
**The header is only passed on from `TUNNEL_PEER`, and only believed from `trusted_proxies`.**
|
||||||
request falls through to a session cookie or the shared token. That is the whole security boundary.
|
Caddy strips `Cf-Access-*` from every request that does not come from `TUNNEL_PEER`, and ipx
|
||||||
|
ignores the header from anyone but Caddy, its one trusted proxy; such a request falls through to a
|
||||||
|
session cookie or the shared token. That is the whole security boundary.
|
||||||
|
|
||||||
With the tunnel reaching ipx through the host's port, `192.168.16.1` means **any container on Tower
|
With the tunnel reaching ipx-web through the host's port, `192.168.64.1` means **any container on Tower
|
||||||
that connects to `192.168.1.130:8099`**, not only `cloudflared`. Machines on the LAN, and Tower
|
that connects to `192.168.1.130:8099`**, not only `cloudflared`. Machines on the LAN, and Tower
|
||||||
itself, arrive under their own addresses and cannot set the header; the checks above show both
|
itself, arrive under their own addresses and cannot set the header; the checks above show both
|
||||||
sides. Never list a LAN address or range: anyone there could then send
|
sides. Never list a LAN address or range: anyone there could then send
|
||||||
@@ -229,5 +247,19 @@ Set `auto_create_users = false` once everyone who should have an account has one
|
|||||||
proxy vouching for an unknown name is logged and refused. Make people ahead of time instead, with
|
proxy vouching for an unknown name is logged and refused. Make people ahead of time instead, with
|
||||||
the exact name the header will carry.
|
the exact name the header will carry.
|
||||||
|
|
||||||
|
## API tokens for scripts and agents
|
||||||
|
|
||||||
|
Anyone can make API tokens for their own account in Settings. A token is sent as
|
||||||
|
`Authorization: Bearer ipx_...` and acts as the person who made it, admin only if they are. Only
|
||||||
|
its SHA-256 is kept; it is shown once, when made, and revoked there too.
|
||||||
|
|
||||||
|
Through the tunnel, Cloudflare Access turns a request with no Access sign-in towards Authentik
|
||||||
|
before ipx sees it, so a token alone does not get in that way. On the LAN, `http://192.168.1.130:8099`
|
||||||
|
takes it directly. From outside, make an Access service token, add a Service Auth policy for it to
|
||||||
|
the `ipodderx` application, and send `CF-Access-Client-Id` and `CF-Access-Client-Secret` beside the
|
||||||
|
`Authorization` header: Access lets the request through, vouches for no name, and ipx takes the
|
||||||
|
API token as who is asking. Do not bypass Access for `/api/*` instead; the token would then be the
|
||||||
|
only thing between the internet and the API.
|
||||||
|
|
||||||
See also [users.md](users.md) for what several people share, [configuration.md](configuration.md)
|
See also [users.md](users.md) for what several people share, [configuration.md](configuration.md)
|
||||||
for every `[web]` key, and [cli.md](cli.md) for the `ipx user` commands.
|
for every `[web]` key, and [cli.md](cli.md) for the `ipx user` commands.
|
||||||
|
|||||||
@@ -70,19 +70,29 @@ list with their own read state. Unsubscribing removes it from their list alone;
|
|||||||
subscriber leaves does the feed stop being scanned, and even then its files and history stay, so
|
subscriber leaves does the feed stop being scanned, and even then its files and history stay, so
|
||||||
re-subscribing does not pull the back catalogue again.
|
re-subscribing does not pull the back catalogue again.
|
||||||
|
|
||||||
**Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also
|
**Directory** sits at the top of the feed list, above your own feeds. It shows the directory
|
||||||
shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you
|
service an admin has set up ([directory.md](directory.md)), which iPX servers share, and says so
|
||||||
included. Directory shows every one of them A to Z as a grid of cover art. Above it, a filter
|
when there is none. It opens on the ten feeds with the most subscribers on every server sharing
|
||||||
picks Podcasts (anything with audio or video) or Blogs (the rest), and chips pick the category
|
with it, you included, ranked, then the categories, each a tile
|
||||||
each show gives itself in iTunes; the two combine. Your own feeds are marked Subscribed.
|
showing a few of its shows' covers. A category has a page of its own: its most subscribed feeds,
|
||||||
|
then all of them as a grid of cover art, with its subcategories along the top (Tech News under
|
||||||
|
News, Video Games under Leisure) and a choice of order. See all lists every feed, and the search
|
||||||
|
box finds one by name. A feed you do not subscribe to opens a page of its own, with what it says it
|
||||||
|
is and its latest episodes or posts, so you can see what it is first. Every page can be narrowed to Podcasts (anything with audio or video) or
|
||||||
|
Blogs (the rest). A show's category is the one it gives itself in iTunes, or for one that names
|
||||||
|
none, one an admin or iPX picked for it. Your own feeds are marked Subscribed.
|
||||||
It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is listed
|
It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is listed
|
||||||
as the feeds inside it, one by one, and never the OPML itself, so you can take just the shows you
|
as the feeds inside it, one by one, and never the OPML itself, so you can take just the shows you
|
||||||
want. Anything that looks private is left out: a login configured for the feed, credentials in its URL,
|
want. Anything private is left out: a feed someone marked Private in its settings, a login configured for the feed, credentials in its URL,
|
||||||
or a key such as `auth=` or `token=` in the query, or a feed from a paid-feed service such as
|
or a key such as `auth=` or `token=` in the query, or a feed from a paid-feed service such as
|
||||||
Patreon or Supercast, which put the key in the path, and any feed inside an OPML that looks private
|
Patreon or Supercast, which put the key in the path, and any feed inside an OPML that looks private
|
||||||
itself. Those are someone's paid subscriptions, and listing them would let anyone here read what
|
itself. Those are someone's paid subscriptions, and listing them would let anyone here read what
|
||||||
they pay for.
|
they pay for.
|
||||||
|
|
||||||
|
**Currently Listening**, under the Directory, is every episode you started and have not finished,
|
||||||
|
across all your feeds, with how much is left. A click picks one up where you left off. The search
|
||||||
|
box looks through it, as it does a feed's items, and through the Directory by name.
|
||||||
|
|
||||||
An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none
|
An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none
|
||||||
for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its
|
for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its
|
||||||
subscriptions and read state with it; downloaded files stay. The only admin cannot be demoted or
|
subscriptions and read state with it; downloaded files stay. The only admin cannot be demoted or
|
||||||
@@ -91,7 +101,7 @@ removed there, so there is always someone who can manage the rest.
|
|||||||
## Admin
|
## Admin
|
||||||
|
|
||||||
The first account is an admin. An admin can change global settings (scanning interval, quota,
|
The first account is an admin. An admin can change global settings (scanning interval, quota,
|
||||||
retention, media types, download folder), a feed's URL, folder and schedule, and who has an account
|
retention, media types, download folder, the directory), a feed's URL, folder and schedule, and who has an account
|
||||||
and who else is an admin, and read the log, which names everyone's feeds and sign-ins. Everyone else
|
and who else is an admin, and read the log, which names everyone's feeds and sign-ins. Everyone else
|
||||||
gets the Settings and Log buttons hidden and a `403` if they ask anyway.
|
gets the Settings and Log buttons hidden and a `403` if they ask anyway.
|
||||||
|
|
||||||
|
|||||||
338
grafana/dashboard.py
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
"""The iPX dashboard in Grafana: the three containers of the Arcane project ipx (ipx, ipx-web,
|
||||||
|
ipx-directory) and Tower under them, from Prometheus, Loki (their logs, shipped by Alloy) and Tempo.
|
||||||
|
|
||||||
|
python3 grafana/dashboard.py > /mnt/fast/arcane/projects/monitoring/grafana-provisioning/dashboards/ipx.json
|
||||||
|
|
||||||
|
Grafana reads that file on its own within a minute; edits made in Grafana are refused. Rows go
|
||||||
|
top-down as a request does: an overview, Tower, then each container (ipx-web, ipx's API, its
|
||||||
|
worker and its process, the directory), then traces and logs. Prometheus scrapes ipx's /metrics
|
||||||
|
(job ipx, #194) and Tower's node exporter (job node); nothing scrapes the containers themselves,
|
||||||
|
so ipx-web and the directory are read from their logs. The log panels read fields: ipx's
|
||||||
|
(web.rs access_log, ipc.rs log_event), the directory's and Caddy's; renaming one has to be
|
||||||
|
matched here. `dashboard.py queries` prints each Loki query, to try against Loki.
|
||||||
|
"""
|
||||||
|
import json, sys
|
||||||
|
|
||||||
|
LOKI = {"type": "loki", "uid": "${loki}"}
|
||||||
|
TEMPO = {"type": "tempo", "uid": "${tempo}"}
|
||||||
|
PROM = {"type": "prometheus", "uid": "${prometheus}"}
|
||||||
|
SEL = '{container="iPX"}'
|
||||||
|
# ipx logs one JSON object a line (IPX_LOG_FORMAT=json). Each scan and download event carries its
|
||||||
|
# fields (ev, feed, new, bytes, msg, error.type, ...); each request its method, path, route, status
|
||||||
|
# and duration_ms. Events are logged under ipx::scan, the healthcheck's status under ipx::io, so
|
||||||
|
# the filter is on the ev field, not the target.
|
||||||
|
EV = SEL + ' |= "\\"ev\\":\\"" | json | __error__="" | ev != ""'
|
||||||
|
HTTP = SEL + ' |= "\\"target\\":\\"ipx::http\\"" | json | __error__="" | path != "/api/events"'
|
||||||
|
BAD = SEL + ' | json | __error__="" | level =~ "WARN|ERROR"'
|
||||||
|
TEXT = ' | line_format "{{.level}} {{.target}}: {{.message}}"'
|
||||||
|
# Every production span: ipx-web's, ipx's and the directory's, one trace from the page to the
|
||||||
|
# directory where a request goes that far (#213). A daemon run by hand has no such tag.
|
||||||
|
TRACES = '{resource.deployment.environment.name="production"'
|
||||||
|
# The directory's JSON log (#204): a line a request (method, path, status, duration_ms, token), a
|
||||||
|
# line a snapshot (instance, accepted, refused_*), a line a refresher round (checked) and one a
|
||||||
|
# failed feed check (feed, error).
|
||||||
|
DIR = '{container="ipx-directory"} | json | __error__=""'
|
||||||
|
DIR_HTTP = '{container="ipx-directory"} |= "ipx_directory::http" | json | __error__=""'
|
||||||
|
# ipx-web's Caddy access log (#203, #210): request.uri, status, duration in seconds. The page and
|
||||||
|
# its files only: /api and /media are ipx's, and in its own log already.
|
||||||
|
WEB = ('{container="ipx-web"} |= "http.log.access" | json | __error__=""'
|
||||||
|
' | request_uri !~ "/(api|media)/.*" | request_remote_ip != "127.0.0.1"') # not its healthcheck
|
||||||
|
# Less what Caddy says at every start, by design: no admin endpoint, and no HTTPS or HTTP/3
|
||||||
|
# behind the tunnel; and its goodbye on a restart.
|
||||||
|
OTHERS_BAD = ('{container=~"ipx-web|ipx-directory"} | json | __error__="" | level =~ "(?i)warn|error"'
|
||||||
|
' | msg !~ "admin endpoint disabled|automatic HTTPS is completely disabled|HTTP/[23] skipped.*|exiting; byeee.*"')
|
||||||
|
|
||||||
|
|
||||||
|
QUERIES = {}
|
||||||
|
panels, y = [], 0
|
||||||
|
pid = 0
|
||||||
|
|
||||||
|
|
||||||
|
def panel(kind, title, w, h, x, targets, **extra):
|
||||||
|
global pid
|
||||||
|
pid += 1
|
||||||
|
p = {"id": pid, "type": kind, "title": title, "gridPos": {"x": x, "y": y, "w": w, "h": h},
|
||||||
|
"datasource": targets[0].get("datasource", LOKI), "targets": targets}
|
||||||
|
p.update(extra)
|
||||||
|
panels.append(p)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def loki(expr, ref="A", legend=None, instant=False, kind=None):
|
||||||
|
QUERIES[expr] = instant
|
||||||
|
t = {"refId": ref, "datasource": LOKI, "expr": expr, "queryType": "instant" if instant else "range"}
|
||||||
|
if legend:
|
||||||
|
t["legendFormat"] = legend
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def prom(expr, ref="A", legend=None):
|
||||||
|
# ipx's /metrics (#194), scraped by Prometheus as job ipx.
|
||||||
|
t = {"refId": ref, "datasource": PROM, "expr": expr}
|
||||||
|
if legend:
|
||||||
|
t["legendFormat"] = legend
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def row(title):
|
||||||
|
global y, pid
|
||||||
|
pid += 1
|
||||||
|
panels.append({"id": pid, "type": "row", "title": title, "collapsed": False,
|
||||||
|
"gridPos": {"x": 0, "y": y, "w": 24, "h": 1}, "panels": []})
|
||||||
|
y += 1
|
||||||
|
|
||||||
|
|
||||||
|
def stat(title, target, x, unit="short", color="blue", thresholds=None, desc=None):
|
||||||
|
"""target: loki(..., instant=True) or prom(...)."""
|
||||||
|
steps = thresholds or [{"color": color, "value": None}]
|
||||||
|
return panel("stat", title, 4, 4, x, [target], description=desc or "",
|
||||||
|
fieldConfig={"defaults": {"unit": unit, "color": {"mode": "thresholds"},
|
||||||
|
"thresholds": {"mode": "absolute", "steps": steps}}, "overrides": []},
|
||||||
|
options={"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False},
|
||||||
|
"colorMode": "value", "graphMode": "none", "textMode": "value"})
|
||||||
|
|
||||||
|
|
||||||
|
def ts(title, targets, x, w=12, h=8, unit="short", bars=False, stack=False, desc=""):
|
||||||
|
custom = {"drawStyle": "bars" if bars else "line", "fillOpacity": 60 if bars else 10,
|
||||||
|
"lineWidth": 1, "showPoints": "never", "stacking": {"mode": "normal" if stack else "none"}}
|
||||||
|
return panel("timeseries", title, w, h, x, targets, description=desc,
|
||||||
|
fieldConfig={"defaults": {"unit": unit, "custom": custom}, "overrides": []},
|
||||||
|
options={"legend": {"displayMode": "list", "placement": "bottom"},
|
||||||
|
"tooltip": {"mode": "multi", "sort": "desc"}})
|
||||||
|
|
||||||
|
|
||||||
|
def table(title, targets, x, w=12, h=8, rename=None, sort=None, desc=""):
|
||||||
|
return panel("table", title, w, h, x, targets, description=desc,
|
||||||
|
transformations=[{"id": "labelsToFields", "options": {"mode": "columns"}},
|
||||||
|
{"id": "organize", "options": {
|
||||||
|
"excludeByName": {"Time": True, "container": True, "compose_project": True,
|
||||||
|
"service_name": True},
|
||||||
|
"renameByName": rename or {}}}],
|
||||||
|
options={"showHeader": True, "sortBy": sort or []},
|
||||||
|
fieldConfig={"defaults": {}, "overrides": []})
|
||||||
|
|
||||||
|
|
||||||
|
GOOD_BAD = [{"color": "green", "value": None}, {"color": "orange", "value": 1}]
|
||||||
|
# Caddy's line for each request, what kind it is by its address.
|
||||||
|
WEB_ALL = ('{container="ipx-web"} |= "http.log.access" | json | __error__="" | request_remote_ip != "127.0.0.1"'
|
||||||
|
' | label_format kind=`{{ if hasPrefix "/api/" .request_uri }}API{{ else if hasPrefix "/media/" .request_uri }}'
|
||||||
|
'media{{ else }}page and files{{ end }}`')
|
||||||
|
LOG_OPTS = {"showTime": True, "wrapLogMessage": True, "sortOrder": "Descending", "enableLogDetails": True}
|
||||||
|
|
||||||
|
|
||||||
|
def bar_overrides(*names):
|
||||||
|
return [{"matcher": {"id": "byName", "options": n},
|
||||||
|
"properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"},
|
||||||
|
{"id": "custom.drawStyle", "value": "bars"}, {"id": "custom.fillOpacity", "value": 60}]}
|
||||||
|
for n in names]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Overview: is each part up, and is anything wrong
|
||||||
|
row("Overview")
|
||||||
|
stat("ipx", prom('max(up{job="ipx"})'), 0, thresholds=[{"color": "red", "value": None}, {"color": "green", "value": 1}],
|
||||||
|
desc="Prometheus reaching ipx's /metrics: 1 is up. Its healthcheck asks the daemon and the database too.")["fieldConfig"]["defaults"]["mappings"] = [
|
||||||
|
{"type": "value", "options": {"0": {"text": "down"}, "1": {"text": "up"}}}]
|
||||||
|
stat("Feeds", prom('max(ipx_feeds{job="ipx"})'), 4)
|
||||||
|
stat("Waiting to download", prom('max(ipx_downloads_pending{job="ipx"})'), 8)
|
||||||
|
stat("Since the last scan", prom('max(ipx_last_scan_age_seconds{job="ipx"})'), 12, unit="s",
|
||||||
|
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 600}, {"color": "red", "value": 1800}],
|
||||||
|
desc="Over ten minutes means the worker may be stuck; the healthcheck still passes.")
|
||||||
|
stat("Page and API errors", loki(f'sum(count_over_time({WEB_ALL} | status >= 500 [$__range])) or vector(0)', instant=True), 16,
|
||||||
|
thresholds=GOOD_BAD, desc="Answers of 500 and up from ipx-web, for the page or passed on from ipx: 502 is ipx not answering.")
|
||||||
|
stat("Warnings and errors", loki(f'sum(count_over_time({BAD} [$__range])) + sum(count_over_time({OTHERS_BAD} [$__range])) or vector(0)', instant=True), 20,
|
||||||
|
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}, {"color": "red", "value": 50}],
|
||||||
|
desc="All three containers, in the time range.")
|
||||||
|
y += 4
|
||||||
|
stat("Feed failures", loki(f'sum(count_over_time({EV} | ev="feed_error" [$__range])) or vector(0)', instant=True), 0,
|
||||||
|
thresholds=GOOD_BAD, desc="Failed feed checks in the time range.")
|
||||||
|
stat("Download failures", loki(f'sum(count_over_time({EV} | ev="download_error" [$__range])) or vector(0)', instant=True), 4,
|
||||||
|
thresholds=GOOD_BAD)
|
||||||
|
stat("Downloaded", prom('max(ipx_files_downloaded{job="ipx"})'), 8, color="green")
|
||||||
|
stat("ipx running for", prom('max(ipx_uptime_seconds{job="ipx"})'), 12, unit="s")
|
||||||
|
stat("Directory: feeds shared", loki('sum(max by (instance) (max_over_time(' + DIR + ' | message="snapshot" | unwrap accepted [6h])))', instant=True), 16,
|
||||||
|
desc="The feeds each server's latest snapshot gave, added up.")
|
||||||
|
stat("Directory: refused as private", loki(f'sum(sum_over_time({DIR} | message="snapshot" | unwrap refused_private [$__range])) or vector(0)', instant=True), 20,
|
||||||
|
thresholds=[{"color": "green", "value": None}, {"color": "red", "value": 1}],
|
||||||
|
desc="Feeds a server sent that the directory caught as private. Not 0 means a server's own check missed one.")
|
||||||
|
y += 4
|
||||||
|
|
||||||
|
# ---- Tower: the host all three run on, from the node exporter
|
||||||
|
row("Tower")
|
||||||
|
ts("Load", [prom('node_load1{job="node"}', legend="1 minute"), prom('node_load15{job="node"}', ref="B", legend="15 minutes")],
|
||||||
|
0, w=8, desc="Twelve threads. Load far above that with little CPU used is processes waiting on a disk or the user shares (shfs).")
|
||||||
|
ts("CPU", [prom('sum by (mode) (rate(node_cpu_seconds_total{job="node", mode=~"user|system|iowait"}[$__rate_interval]))', legend="{{mode}}")],
|
||||||
|
8, w=8, stack=True, desc="Cores busy, by kind. Much system time with little user time is the kernel, often FUSE (shfs).")
|
||||||
|
ts("Memory available", [prom('node_memory_MemAvailable_bytes{job="node"}', legend="available")], 16, w=8, unit="bytes")
|
||||||
|
y += 8
|
||||||
|
ts("Disk busy", [prom('rate(node_disk_io_time_seconds_total{job="node", device=~"nvme.*|md.*"}[$__rate_interval])', legend="{{device}}")],
|
||||||
|
0, w=12, unit="percentunit", desc="nvme1n1 is /mnt/fast (appdata, Postgres's data); md* are the array's disks behind /mnt/user (downloads).")
|
||||||
|
ts("Network", [prom('sum(rate(node_network_receive_bytes_total{job="node", device!~"lo|veth.*|docker.*|br-.*"}[$__rate_interval]))', legend="in"),
|
||||||
|
prom('sum(rate(node_network_transmit_bytes_total{job="node", device!~"lo|veth.*|docker.*|br-.*"}[$__rate_interval]))', ref="B", legend="out")],
|
||||||
|
12, w=12, unit="Bps")
|
||||||
|
y += 8
|
||||||
|
|
||||||
|
# ---- ipx-web: Caddy, the way in. Serves the page itself, passes /api and /media to ipx
|
||||||
|
row("ipx-web")
|
||||||
|
ts("Requests by kind", [loki(f'sum by (kind) (count_over_time({WEB_ALL} [$__interval]))', legend="{{kind}}")],
|
||||||
|
0, w=8, bars=True, stack=True, desc="Everything through Caddy, from the LAN and the tunnel. The API and media go on to ipx.")
|
||||||
|
ts("Page and files by status", [loki(f'sum by (status) (count_over_time({WEB} [$__interval]))', legend="{{status}}")],
|
||||||
|
8, w=8, bars=True, stack=True, desc="What Caddy served itself. A page answered 401 or 403 is ipx refusing it in /api/page-check.")
|
||||||
|
ts("Page response time", [loki(f'quantile_over_time(0.95, {WEB} | unwrap duration [$__interval]) by ()', legend="95th percentile"),
|
||||||
|
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
|
||||||
|
|
||||||
|
# ---- ipx: the API it answers behind ipx-web
|
||||||
|
row("ipx: API")
|
||||||
|
ts("Requests by status", [loki(f'sum by (status) (count_over_time({HTTP} [$__interval]))', legend="{{status}}")],
|
||||||
|
0, bars=True, stack=True, desc="The event stream the page keeps open is left out.")
|
||||||
|
ts("Response time", [loki(f'quantile_over_time(0.5, {HTTP} | unwrap duration_ms [$__interval]) by ()', legend="median"),
|
||||||
|
loki(f'quantile_over_time(0.95, {HTTP} | unwrap duration_ms [$__interval]) by ()', ref="B", legend="95th percentile"),
|
||||||
|
loki(f'max_over_time({HTTP} | unwrap duration_ms [$__interval]) by ()', ref="C", legend="slowest")],
|
||||||
|
12, unit="ms")
|
||||||
|
y += 8
|
||||||
|
table("Slowest routes", [loki(f'topk(15, avg_over_time({HTTP} | route != "" | unwrap duration_ms [$__range]) by (method, route))', instant=True)],
|
||||||
|
0, w=8, rename={"method": "Method", "route": "Route", "Value": "Average ms"}, sort=[{"displayName": "Average ms", "desc": True}])
|
||||||
|
table("Busiest routes", [loki(f'topk(15, sum by (method, route) (count_over_time({HTTP} | route != "" [$__range])))', instant=True)],
|
||||||
|
8, w=8, rename={"method": "Method", "route": "Route", "Value": "Requests"}, sort=[{"displayName": "Requests", "desc": True}])
|
||||||
|
ts("Open connections", [prom('max(ipx_http_requests_in_flight{job="ipx"})', legend="requests in flight"),
|
||||||
|
prom('max(ipx_event_listeners{job="ipx"})', ref="B", legend="live-update listeners"),
|
||||||
|
prom('max(ipx_event_queue{job="ipx"})', ref="C", legend="live updates unread"),
|
||||||
|
prom('sum(increase(ipx_events_missed_total{job="ipx"}[$__rate_interval]))', ref="D", legend="live updates missed")],
|
||||||
|
16, w=8, desc="Each open page holds one live-update listener. Missed updates mean a page fell behind and reloads its lists.")
|
||||||
|
y += 8
|
||||||
|
|
||||||
|
# ---- ipx: the worker, one job at a time: scans, downloads, DeadAir
|
||||||
|
row("ipx: worker")
|
||||||
|
ts("Worker", [prom('max by (kind) (ipx_worker_job_seconds{job="ipx"})', legend="on a {{kind}} for"),
|
||||||
|
prom('max(ipx_last_scan_age_seconds{job="ipx"})', ref="B", legend="since the last scan finished")],
|
||||||
|
0, w=8, unit="s", desc="A job's time that only climbs, or no scan for over 10 minutes, means the worker is stuck; the healthcheck still passes.")
|
||||||
|
ts("Jobs done", [prom('sum by (kind) (increase(ipx_jobs_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
|
||||||
|
8, w=8, bars=True, stack=True)
|
||||||
|
ts("Time on jobs", [prom('sum by (kind) (increase(ipx_job_seconds_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
|
||||||
|
16, w=8, unit="s", bars=True, stack=True, desc="Seconds the worker spent on each kind of job in each interval.")
|
||||||
|
y += 8
|
||||||
|
ts("Waiting", [prom('ipx_worker_queue{job="ipx"}', legend="commands for the worker"),
|
||||||
|
prom('ipx_downloads_pending{job="ipx"}', ref="B", legend="downloads"),
|
||||||
|
prom('max(ipx_feed_retries{job="ipx"})', ref="C", legend="feeds to retry a lookup"),
|
||||||
|
prom('max(ipx_deadair_backlog{job="ipx"})', ref="D", legend="files for DeadAir")],
|
||||||
|
0, w=8, desc="A queue that only grows means the worker is stuck or behind.")
|
||||||
|
ts("Feeds checked and new items", [loki(f'sum(sum_over_time({EV} | ev="scan_done" | unwrap feeds [$__interval]))', legend="feeds checked"),
|
||||||
|
loki(f'sum(sum_over_time({EV} | ev="feed_done" | unwrap new [$__interval]))', ref="B", legend="new items")],
|
||||||
|
8, w=8, bars=True, desc="Feeds that were due and fetched; the rest were skipped as not due.")
|
||||||
|
ts("Downloads", [loki(f'sum(count_over_time({EV} | ev="download_done" [$__interval]))', legend="saved"),
|
||||||
|
loki(f'sum(count_over_time({EV} | ev="download_error" [$__interval]))', ref="B", legend="failed"),
|
||||||
|
prom('max(ipx_downloads_active{job="ipx"})', ref="C", legend="at once"),
|
||||||
|
prom('2 - max(ipx_torrent_slots_free{job="ipx"})', ref="D", legend="torrents")],
|
||||||
|
16, w=8, bars=True)
|
||||||
|
y += 8
|
||||||
|
ts("Download speed", [prom('sum(rate(ipx_download_bytes_total{job="ipx"}[$__rate_interval]))', legend="downloading")],
|
||||||
|
0, w=8, unit="Bps")
|
||||||
|
ts("Feeds fetching at once", [prom('max(ipx_scan_feeds_fetching{job="ipx"})', legend="fetching")], 8, w=8)
|
||||||
|
ts("DeadAir on a file", [prom('max(ipx_deadair_busy_seconds{job="ipx"})', legend="for"),
|
||||||
|
prom('max(ipx_deadair_playing{job="ipx"})', ref="B", legend="playing through DeadAir")],
|
||||||
|
16, w=8, unit="s", desc="How long DeadAir has been on the file it is analysing; a few seconds each.")["fieldConfig"]["overrides"] = [
|
||||||
|
{"matcher": {"id": "byName", "options": "playing through DeadAir"},
|
||||||
|
"properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"}]}]
|
||||||
|
y += 8
|
||||||
|
table("Failing feeds", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="feed_error" [$__range]))', instant=True)],
|
||||||
|
0, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
|
||||||
|
table("Failed downloads", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="download_error" [$__range]))', instant=True)],
|
||||||
|
12, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
|
||||||
|
y += 8
|
||||||
|
|
||||||
|
# ---- ipx: the process, what it holds between requests and jobs (#194)
|
||||||
|
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"}}]}]
|
||||||
|
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
|
||||||
|
ts("Runtime", [prom('ipx_runtime_tasks{job="ipx"}', legend="tasks"),
|
||||||
|
prom('ipx_runtime_queue{job="ipx"}', ref="B", legend="tasks waiting for a thread"),
|
||||||
|
prom('ipx_runtime_workers{job="ipx"}', ref="C", legend="threads")],
|
||||||
|
0, w=12, desc="Tasks waiting for a thread for long means something is blocking the runtime.")
|
||||||
|
ts("Artwork", [prom('max(ipx_art_cache_bytes{job="ipx"}) / max(ipx_art_cache_limit_bytes{job="ipx"})', legend="full"),
|
||||||
|
prom('sum(increase(ipx_art_requests_total{job="ipx", result="hit"}[$__rate_interval])) / '
|
||||||
|
'sum(increase(ipx_art_requests_total{job="ipx"}[$__rate_interval]))', ref="B", legend="hit rate"),
|
||||||
|
prom('sum(increase(ipx_art_evicted_total{job="ipx"}[$__rate_interval]))', ref="C", legend="images evicted")],
|
||||||
|
12, w=12, unit="percentunit",
|
||||||
|
desc="Full sits near 100% once the cache has filled, since each sweep trims it to fit. Hit rate is the share served from disk: "
|
||||||
|
"the rest was fetched from the publisher. Evicted, on the right, is images each sweep threw out to stay under the "
|
||||||
|
"limit: many at every sweep, with a falling hit rate, means the limit is too small.")["fieldConfig"]["overrides"] = bar_overrides("images evicted")
|
||||||
|
y += 8
|
||||||
|
|
||||||
|
# ---- ipx-directory: public at directory.ipxreader.app, shared by ipx servers (#204)
|
||||||
|
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.")
|
||||||
|
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)
|
||||||
|
stat("Requests refused", loki(f'sum(count_over_time({DIR_HTTP} | status =~ "401|403" [$__range])) or vector(0)', instant=True), 16,
|
||||||
|
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 100}],
|
||||||
|
desc="Without a token, or with one that has not the access asked for. Now and then is someone trying a public address.")
|
||||||
|
stat("Slowest request", loki(f'max(max_over_time({DIR_HTTP} | unwrap duration_ms [$__range])) or vector(0)', instant=True), 20, unit="ms")
|
||||||
|
y += 4
|
||||||
|
ts("Requests by token", [loki(f'sum by (status, token) (count_over_time({DIR_HTTP} [$__interval]))', legend="{{status}} {{token}}")],
|
||||||
|
0, bars=True, stack=True)
|
||||||
|
ts("Feed checks over time", [loki(f'sum(sum_over_time({DIR} | message="feeds checked" | unwrap checked [$__interval]))', legend="checked"),
|
||||||
|
loki(f'sum(count_over_time({DIR} | message="feed check failed" [$__interval]))', ref="B", legend="failed")],
|
||||||
|
12, bars=True, desc="A burst follows a snapshot with new feeds.")
|
||||||
|
y += 8
|
||||||
|
table("Failing feeds in the directory", [loki(f'sum by (feed, error) (count_over_time({DIR} | message="feed check failed" [$__range]))', instant=True)],
|
||||||
|
0, rename={"feed": "Feed id", "error": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
|
||||||
|
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
|
||||||
|
|
||||||
|
# ---- Traces: all three send them to Tempo (#213)
|
||||||
|
row("Traces")
|
||||||
|
for x, title, q in [(0, "Recent", TRACES + "}"),
|
||||||
|
(8, "Slow (over 2s)", TRACES + " && duration > 2s}"),
|
||||||
|
(16, "Failed", TRACES + " && status = error}")]:
|
||||||
|
panel("table", title, 8, 10, x,
|
||||||
|
[{"refId": "A", "datasource": TEMPO, "queryType": "traceql", "query": q, "limit": 50,
|
||||||
|
"tableType": "traces"}],
|
||||||
|
fieldConfig={"defaults": {}, "overrides": []})
|
||||||
|
y += 10
|
||||||
|
|
||||||
|
# ---- Logs
|
||||||
|
row("Logs")
|
||||||
|
panel("logs", "ipx: warnings and errors", 12, 10, 0, [loki(BAD + TEXT)], options=LOG_OPTS)
|
||||||
|
panel("logs", "ipx-web and ipx-directory: warnings and errors", 12, 10, 12,
|
||||||
|
[loki(OTHERS_BAD + ' | line_format "{{.container}} {{.level}}: {{.message}}{{.msg}}"')], options=LOG_OPTS)
|
||||||
|
y += 10
|
||||||
|
panel("logs", "ipx log", 24, 12, 0,
|
||||||
|
[loki(SEL + ' | json | __error__="" | path != "/api/events" | ev != "feed_skip" | ev != "status"'
|
||||||
|
' | message != "-> {\\"cmd\\":\\"status\\"}"' + TEXT)],
|
||||||
|
description="Without the event stream's requests, not-due skips and healthcheck status calls.", options=LOG_OPTS)
|
||||||
|
y += 12
|
||||||
|
|
||||||
|
dash = {
|
||||||
|
"uid": "ipx", "title": "iPX", "tags": ["ipx"], "timezone": "browser", "schemaVersion": 39,
|
||||||
|
"time": {"from": "now-24h", "to": "now"}, "refresh": "1m", "editable": True,
|
||||||
|
"templating": {"list": [
|
||||||
|
{"name": "loki", "label": "Logs", "type": "datasource", "query": "loki", "current": {}, "hide": 0},
|
||||||
|
{"name": "tempo", "label": "Traces", "type": "datasource", "query": "tempo", "current": {}, "hide": 0},
|
||||||
|
{"name": "prometheus", "label": "Metrics", "type": "datasource", "query": "prometheus", "current": {}, "hide": 0},
|
||||||
|
]},
|
||||||
|
"links": [{"title": "iPX", "type": "link", "url": "https://ipodderx.sdf1.net", "targetBlank": True}],
|
||||||
|
"panels": panels,
|
||||||
|
}
|
||||||
|
|
||||||
|
if sys.argv[1:] == ["queries"]:
|
||||||
|
for q, instant in QUERIES.items():
|
||||||
|
print(json.dumps([q, instant]))
|
||||||
|
else:
|
||||||
|
print(json.dumps(dash, indent=2))
|
||||||
9
package-lock.json
generated
@@ -7,7 +7,8 @@
|
|||||||
"name": "ipx-web",
|
"name": "ipx-web",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/core": "^1.16.2",
|
"@swc/core": "^1.16.2",
|
||||||
"@swc/html": "^1.16.2"
|
"@swc/html": "^1.16.2",
|
||||||
|
"hls.js": "1.6.19"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.0",
|
"@playwright/test": "^1.56.0",
|
||||||
@@ -871,6 +872,12 @@
|
|||||||
"node": ">=16.20.0"
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hls.js": {
|
||||||
|
"version": "1.6.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.19.tgz",
|
||||||
|
"integrity": "sha512-A//aZeaAgM5rgUCbTURD4gkfyw5Uag2/4Jky7CfDOukL/DN+P5DjbgK2BYteyP3/KnCkPfkGVkbLxQG2T0AbUQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.63.0",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ipx-web",
|
"name": "ipx-web",
|
||||||
|
"version": "1.0.0",
|
||||||
"private": true,
|
"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.",
|
"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": {
|
"scripts": {
|
||||||
@@ -7,7 +8,8 @@
|
|||||||
"typecheck": "tsc -p .",
|
"typecheck": "tsc -p .",
|
||||||
"smoke": "node tests/page-smoke.js",
|
"smoke": "node tests/page-smoke.js",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
"test:headed": "playwright test --headed"
|
"test:headed": "playwright test --headed",
|
||||||
|
"load": "node tests/load/run.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.0",
|
"@playwright/test": "^1.56.0",
|
||||||
@@ -15,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/core": "^1.16.2",
|
"@swc/core": "^1.16.2",
|
||||||
"@swc/html": "^1.16.2"
|
"@swc/html": "^1.16.2",
|
||||||
|
"hls.js": "1.6.19"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,29 @@ module.exports = defineConfig({
|
|||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
stdout: 'ignore',
|
stdout: 'ignore',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// The directory the Directory shows (#205), on SQLite in the scratch directory.
|
||||||
|
command: `cargo build -q -p ipx-directory && exec ./target/debug/ipx-directory --config ${setup.root}/directory.toml`,
|
||||||
|
url: `${setup.DIRECTORY}/healthz`,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 180_000,
|
||||||
|
stdout: 'ignore',
|
||||||
|
env: { DIRECTORY_DATABASE_URL: `sqlite://${setup.root}/directory.db?mode=rwc` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The frontend as production serves it (#203): built from web/, served by Caddy on the
|
||||||
|
// suite's port, the API passed to the daemon behind it.
|
||||||
|
command: `node web/build.mjs ${setup.root}/web && exec caddy run --config web/Caddyfile --adapter caddyfile`,
|
||||||
|
url: 'http://127.0.0.1:8791/favicon.png',
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 60_000,
|
||||||
|
stdout: 'ignore',
|
||||||
|
env: { WEB_BIND: '127.0.0.1:8791', WEB_ROOT: `${setup.root}/web`, IPX_UPSTREAM: '127.0.0.1:8796', HOME: setup.root, XDG_DATA_HOME: setup.root, XDG_CONFIG_HOME: setup.root },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Build first so the tests always run against current source.
|
// Build first so the tests always run against current source.
|
||||||
command: 'cargo build -q && exec ./target/debug/ipx daemon',
|
command: 'cargo build -q && exec ./target/debug/ipx daemon',
|
||||||
port: 8791,
|
port: 8796,
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
timeout: 180_000,
|
timeout: 180_000,
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
@@ -37,6 +56,10 @@ module.exports = defineConfig({
|
|||||||
IPX_CONFIG: `${setup.root}/config/config.toml`,
|
IPX_CONFIG: `${setup.root}/config/config.toml`,
|
||||||
IPX_DATA_DIR: `${setup.root}/data`,
|
IPX_DATA_DIR: `${setup.root}/data`,
|
||||||
IPX_LOG: 'ipx=info',
|
IPX_LOG: 'ipx=info',
|
||||||
|
// Blank, whatever the shell running the suite has: the test daemon sends no traces to
|
||||||
|
// production's Tempo and never opens production's database.
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: '',
|
||||||
|
IPX_DATABASE_URL: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
46
scripts/release.sh
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Publish a Gitea release for one service's tag, with a tarball taken from the image that was
|
||||||
|
# deployed, so what is attached is what runs on Tower. Run after the release commit is tagged,
|
||||||
|
# pushed and its image built and pushed: sh scripts/release.sh ipx|directory|web
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
ipx) name=ipx; ver=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ipx/Cargo.toml | head -1)
|
||||||
|
tag=v$ver; log=crates/ipx/CHANGELOG.md; from=/usr/local/bin/ipx ;;
|
||||||
|
directory) name=ipx-directory; ver=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/directory/Cargo.toml | head -1)
|
||||||
|
tag=directory-v$ver; log=crates/directory/CHANGELOG.md; from=/usr/local/bin/ipx-directory ;;
|
||||||
|
web) name=ipx-web; ver=$(node -p 'require("./package.json").version')
|
||||||
|
tag=web-v$ver; log=web/CHANGELOG.md; from=/srv ;;
|
||||||
|
*) echo "usage: $0 ipx|directory|web" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$ver" in *-dev) echo "$name is at $ver: release it first" >&2; exit 1 ;; esac
|
||||||
|
git fetch -q --tags origin
|
||||||
|
git merge-base --is-ancestor "$tag" origin/main || { echo "$tag is not on origin/main" >&2; exit 1; }
|
||||||
|
|
||||||
|
out=$(mktemp -d); trap 'rm -rf "$out"' EXIT
|
||||||
|
dir=$out/$name-$ver; mkdir "$dir"
|
||||||
|
image=192.168.1.130:5000/$name:latest
|
||||||
|
c=$(docker create "$image"); trap 'docker rm -f "$c" >/dev/null; rm -rf "$out"' EXIT
|
||||||
|
if [ "$name" = ipx-web ]; then
|
||||||
|
docker cp -q "$c:$from/." "$dir/"
|
||||||
|
cp web/Caddyfile "$dir/"
|
||||||
|
# latest may be a later build than the tag: the page carries its version, so check it.
|
||||||
|
grep -qF "$ver" "$dir"/app.js || { echo "$image is not ipx-web $ver" >&2; exit 1; }
|
||||||
|
arch=any
|
||||||
|
else
|
||||||
|
docker cp -q "$c:$from" "$dir/"
|
||||||
|
"$dir/$name" --version | grep -qF " $ver" || { echo "$image is not $name $ver" >&2; exit 1; }
|
||||||
|
arch=linux-$(uname -m)
|
||||||
|
fi
|
||||||
|
cp LICENSE README.md "$dir/"; cp "$log" "$dir/CHANGELOG.md"
|
||||||
|
tarball=$out/$name-$ver-$arch.tar.gz
|
||||||
|
tar -C "$out" -czf "$tarball" "$name-$ver"
|
||||||
|
|
||||||
|
# The notes are this version's section of the changelog, without its heading.
|
||||||
|
awk -v h="## [$ver]" 'index($0, h) == 1 {p = 1; next} p && /^## \[/ {exit} p' "$log" > "$out/notes.md"
|
||||||
|
[ -s "$out/notes.md" ] || { echo "no [$ver] section in $log" >&2; exit 1; }
|
||||||
|
|
||||||
|
timeout 120 /src/tea releases create --login git.sdf1.net --repo rays/ipx --tag "$tag" \
|
||||||
|
--title "$name $ver" --note-file "$out/notes.md" --asset "$tarball" < /dev/null
|
||||||
7
site/_headers
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Cloudflare Web Analytics, which Pages adds to the page, is the one script it runs (#216).
|
||||||
|
/*
|
||||||
|
Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'self'; font-src 'self'; script-src https://static.cloudflareinsights.com; connect-src 'self' https://cloudflareinsights.com; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
Referrer-Policy: strict-origin-when-cross-origin
|
||||||
|
/fonts/*
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
12
site/build.sh
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ipxreader.app: the page and its stylesheet, and the typeface, logo and icon from web/, into
|
||||||
|
# site/dist, which Cloudflare Pages serves.
|
||||||
|
set -e
|
||||||
|
here=$(dirname "$0")
|
||||||
|
rm -rf "$here/dist"
|
||||||
|
mkdir -p "$here/dist/fonts"
|
||||||
|
cp "$here/index.html" "$here/site.css" "$here/wave.svg" "$here/gitea.svg" "$here/_headers" "$here/dist/"
|
||||||
|
# The reader's own typeface, the same file ipx-web serves.
|
||||||
|
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/"
|
||||||
1
site/gitea.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="32" height="32"><path d="m395.9 484.2-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5 21.2-17.9 33.8-11.8 17.2 8.3 27.1 13 27.1 13l-.1-109.2 16.7-.1.1 117.1s57.4 24.2 83.1 40.1c3.7 2.3 10.2 6.8 12.9 14.4 2.1 6.1 2 13.1-1 19.3l-61 126.9c-6.2 12.7-21.4 18.1-33.9 12" fill="#fff"/><path d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2c-5.5-2.6-11.1-5.3-16.6-7.9 0-36.4-.1-109.2-.1-109.2-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9C-4.9 212.4 6.6 276.2 8 285.8c1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4S547 483 565 451.5c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6M125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1m300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c.6-.3 2.3-1 4.5-1.5 8.1-2.1 18 2.8 18 2.8L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1" fill="#609926"/><path d="M326.8 380.1c-8.2.1-15.4 5.8-17.3 13.8s2 16.3 9.1 20c7.7 4 17.5 1.8 22.7-5.4 5.1-7.1 4.3-16.9-1.8-23.1l24-49.1c1.5.1 3.7.2 6.2-.5 4.1-.9 7.1-3.6 7.1-3.6 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3.9.5 1.8 1.1 2.8 1.9 1.6 1.3 3.4 3.1 4.7 5.5 1.9 5.5-1.9 14.9-1.9 14.9-2.3 7.6-18.4 40.6-18.4 40.6-8.1-.2-15.3 5-17.7 12.5-2.6 8.1 1.1 17.3 8.9 21.3s17.4 1.7 22.5-5.3c5-6.8 4.6-16.3-1.1-22.6 1.9-3.7 3.7-7.4 5.6-11.3 5-10.4 13.5-30.4 13.5-30.4.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3 4.7-9.7 9.4-19.3 14.1-29-4.1-2-8.1-4-12.2-6.1-4.8 9.8-9.7 19.7-14.5 29.5-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8z" fill="#609926"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
138
site/index.html
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>iPX</title>
|
||||||
|
<meta name="description" content="iPX checks your podcasts, downloads new episodes and gives everyone at home an account of their own, on your own server. The successor to iPodderX, free and open source.">
|
||||||
|
<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="https://directory.ipxreader.app">Directory</a>
|
||||||
|
<a class="src" href="https://git.sdf1.net/rays/ipx" title="Source code on git.sdf1.net"><img src="/gitea.svg" alt="Source code" width="28" height="28"></a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="hero">
|
||||||
|
<h1>iPX</h1>
|
||||||
|
<p class="intro">iPX checks your podcasts, downloads new episodes, and gives everyone at home their own account to listen from in the browser. It runs on your own server.</p>
|
||||||
|
<p class="acts">
|
||||||
|
<a class="btn primary" href="https://directory.ipxreader.app">Browse the directory</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- The iPodderX logo is a radio's tuning scale and needle. Here the scale is full size and its
|
||||||
|
stations are shows from the directory; the needle sweeps once and settles. -->
|
||||||
|
<figure class="dial" aria-label="A radio dial tuned across shows in the iPX directory">
|
||||||
|
<div class="scale"><div class="track">
|
||||||
|
<ol class="freq" aria-hidden="true">
|
||||||
|
<li>88</li><li>92</li><li>96</li><li>100</li><li>104</li><li>108</li>
|
||||||
|
</ol>
|
||||||
|
<ul class="stations">
|
||||||
|
<li>99% Invisible</li>
|
||||||
|
<li>Hackaday</li>
|
||||||
|
<li>Clarkesworld</li>
|
||||||
|
<li>Accidental Tech Podcast</li>
|
||||||
|
<li>Daring Fireball</li>
|
||||||
|
<li class="tuned">Ologies</li>
|
||||||
|
<li>Oxide and Friends</li>
|
||||||
|
<li>xkcd</li>
|
||||||
|
<li>The Sporkful</li>
|
||||||
|
<li>Acquired</li>
|
||||||
|
<li>Construction Physics</li>
|
||||||
|
<li>Dwarkesh Podcast</li>
|
||||||
|
</ul>
|
||||||
|
<span class="needle" aria-hidden="true"></span>
|
||||||
|
</div></div>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<!-- Each feature beside the part of the app that shows it, cut from a scratch iPX on public
|
||||||
|
podcasts, in a navy frame like the dial's. -->
|
||||||
|
<section class="does" aria-labelledby="does">
|
||||||
|
<h2 id="does">What it does</h2>
|
||||||
|
|
||||||
|
<div class="feature">
|
||||||
|
<div class="say">
|
||||||
|
<h3>An account for everyone at home</h3>
|
||||||
|
<p>Each person has their own shows, sees what they have and have not heard, and picks up where they left off.</p>
|
||||||
|
</div>
|
||||||
|
<figure class="frame"><img src="/shots/feeds.webp" width="290" height="276" loading="lazy"
|
||||||
|
alt="The feed list: Directory, Currently Listening and All Subscriptions, then each show with its cover and how many of its episodes are unread."></figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature">
|
||||||
|
<div class="say">
|
||||||
|
<h3>New episodes, ready to play</h3>
|
||||||
|
<p>iPX checks your podcasts through the day and downloads new episodes as they come out. Skip the ones you don't want by keyword, or leave out explicit ones. Old episodes are cleared to stay within the space you give it, but never one someone has pinned.</p>
|
||||||
|
</div>
|
||||||
|
<figure class="frame"><img class="cut" src="/shots/episodes.webp" width="904" height="283" loading="lazy"
|
||||||
|
alt="99% Invisible's episodes in a table with each one's size and date, and the downloaded one's file beside it, ready to play, save, share or delete."></figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature">
|
||||||
|
<div class="say">
|
||||||
|
<h3>Pick up where you left off</h3>
|
||||||
|
<p>The player keeps your place in every episode, on every device.</p>
|
||||||
|
</div>
|
||||||
|
<figure class="frame"><img class="cut player" src="/shots/player.webp" width="950" height="80" loading="lazy"
|
||||||
|
alt="The player along the bottom of the page: the episode playing, back and forward, and how far in it is."></figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DeadAir: talk keeps its length, the quiet between it shrinks to a short pause, and the
|
||||||
|
episode ends sooner. CSS only; the page runs no script of its own. -->
|
||||||
|
<div class="feature">
|
||||||
|
<div class="say">
|
||||||
|
<h3>Cut the dead air</h3>
|
||||||
|
<p>Turn on DeadAir for a show and its long silences shrink to a short pause, so episodes finish sooner without anyone talking faster. You choose how long a pause to leave.</p>
|
||||||
|
</div>
|
||||||
|
<figure class="frame deadair" role="img" aria-label="A waveform whose silent gaps shrink to short pauses, so the episode ends sooner.">
|
||||||
|
<div class="wave" aria-hidden="true">
|
||||||
|
<span class="talk t1"></span><span class="hush"></span>
|
||||||
|
<span class="talk t2"></span><span class="hush"></span>
|
||||||
|
<span class="talk t3"></span><span class="hush"></span>
|
||||||
|
<span class="talk t4"></span><span class="hush"></span>
|
||||||
|
<span class="talk t5"></span><span class="hush"></span>
|
||||||
|
<span class="talk t6"></span>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature">
|
||||||
|
<div class="say">
|
||||||
|
<h3>See what others listen to</h3>
|
||||||
|
<p>iPX servers can share which podcasts their listeners follow, and the directory gathers them in one place. <a href="https://directory.ipxreader.app">Browse the directory</a> to find something new and subscribe in one click.</p>
|
||||||
|
</div>
|
||||||
|
<figure class="frame"><img class="cut half" src="/shots/directory.webp" width="700" height="133" loading="lazy"
|
||||||
|
alt="The directory's most subscribed shows, each with its cover and how many people subscribe."></figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="also">
|
||||||
|
<div><dt>BitTorrent too</dt>
|
||||||
|
<dd>Some shows publish their episodes as torrents. iPX downloads them like any other, shares them back for as long as you choose, and a slow one never holds up the rest.</dd></div>
|
||||||
|
<div><dt>Subscribe to a whole list</dt>
|
||||||
|
<dd>Some people publish lists of podcasts they recommend, as OPML. Give iPX a list's address and it keeps a folder of those shows up to date as the list changes. Your own subscriptions move in and out the same way.</dd></div>
|
||||||
|
<div><dt>Sign in your way</dt>
|
||||||
|
<dd>Everyone signs in with a password, or with the single sign-on you already use, Cloudflare Access or Authentik. An admin adds people and decides who else is one.</dd></div>
|
||||||
|
<div><dt>Blogs too</dt>
|
||||||
|
<dd>Any RSS or Atom feed works, so the blogs you read sit beside the shows you listen to, and iPX keeps track of what you have read.</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="then" aria-labelledby="then">
|
||||||
|
<h2 id="then">Since 2004</h2>
|
||||||
|
<p>iPodderX was one of the first podcatchers, a Mac app by Ray Slakinski and August Trometer, from the year podcasts got their name. iPX is that app rebuilt to run on your own server.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>iPX is free and open source under the MIT licence.</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
site/shots/directory.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
site/shots/episodes.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
site/shots/feeds.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
site/shots/player.webp
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
114
site/site.css
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/* ipxreader.app. The palette is the iPodderX logo's: the radio's navy body, the sky of its
|
||||||
|
screen, the blue of its tuning scale, and the orange needle, which is the needle alone. The
|
||||||
|
typeface is the reader's own, Inter, the same file (web/InterVariable.woff2). */
|
||||||
|
@font-face{font-family:Inter;src:url(/fonts/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
|
||||||
|
:root{
|
||||||
|
--navy:#0c2238; /* the radio */
|
||||||
|
--ink:#16304a; /* body text, a step off the navy */
|
||||||
|
--muted:#46607a;
|
||||||
|
--sky:#dff0fb; /* its screen */
|
||||||
|
--page:#f1f8fd;
|
||||||
|
--scale:#2f6aa0; /* the tuning scale, and links */
|
||||||
|
--tick:#8fc2ea; /* the scale on the dark dial */
|
||||||
|
--needle:#ff5500;
|
||||||
|
--rule:#c6d9ea;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html{-webkit-text-size-adjust:100%;scroll-behavior:smooth}
|
||||||
|
body{margin:0;background:var(--page);color:var(--ink);font:400 1.0625rem/1.6 Inter,system-ui,sans-serif}
|
||||||
|
a{color:var(--scale);text-underline-offset:.18em}
|
||||||
|
a:focus-visible,.btn:focus-visible{outline:3px solid var(--scale);outline-offset:3px;border-radius:6px}
|
||||||
|
main,.bar,footer{max-width:72rem;margin:0 auto;padding-inline:1.5rem}
|
||||||
|
|
||||||
|
.bar{display:flex;align-items:center;justify-content:space-between;padding-block:1.25rem}
|
||||||
|
.home{display:flex;align-items:center;gap:.6rem;color:var(--navy);text-decoration:none;font-weight:800;font-size:1.25rem}
|
||||||
|
.home img{border-radius:9px;display:block}
|
||||||
|
.bar nav{display:flex;gap:1.5rem}
|
||||||
|
.bar nav a{color:var(--navy);text-decoration:none;font-weight:600}
|
||||||
|
.bar nav a:hover{text-decoration:underline}
|
||||||
|
.bar nav{align-items:center}
|
||||||
|
.src img{display:block}
|
||||||
|
|
||||||
|
.hero{padding-block:3rem 2.5rem;max-width:46rem}
|
||||||
|
h1{margin:0;color:var(--navy);font-weight:900;font-size:clamp(6.5rem,24vw,15rem);line-height:.82;letter-spacing:-.065em}
|
||||||
|
.intro{margin:1.75rem 0 0;max-width:38rem;color:var(--muted);font-size:1.125rem}
|
||||||
|
.acts{display:flex;flex-wrap:wrap;gap:.75rem;margin:2rem 0 0}
|
||||||
|
.btn{display:inline-block;padding:.8rem 1.35rem;border:2px solid var(--navy);border-radius:999px;color:var(--navy);text-decoration:none;font-weight:700}
|
||||||
|
.btn:hover{background:var(--sky)}
|
||||||
|
.btn.primary{background:var(--navy);color:#fff}
|
||||||
|
.btn.primary:hover{background:#1a3a5c}
|
||||||
|
|
||||||
|
/* The dial: a frequency row, minor and major ticks, stations on two staggered rows as a printed
|
||||||
|
dial has them, and the needle, which points down at the scale and lights the station it stops
|
||||||
|
on rather than crossing its name. --at is where it stops: the tuned station's middle. */
|
||||||
|
.dial{margin:1rem 0 0}
|
||||||
|
.scale{background:var(--navy);border-radius:1.25rem;overflow:hidden}
|
||||||
|
.track{position:relative;--at:45.8%}
|
||||||
|
.freq{position:relative;list-style:none;margin:0;padding:1.1rem 4% 0;display:flex;justify-content:space-between;color:var(--tick);font-weight:600;font-size:.95rem;font-variant-numeric:tabular-nums}
|
||||||
|
.freq li{width:0;display:flex;justify-content:center} /* each number centred on its tick */
|
||||||
|
.freq::after{content:"";position:absolute;left:4%;right:4%;top:2.6rem;height:1.6rem;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(90deg,var(--tick) 0 2px,transparent 2px 20%) 0 0/100% 100%,
|
||||||
|
repeating-linear-gradient(90deg,rgba(143,194,234,.55) 0 1px,transparent 1px 2%) 0 100%/100% 45%;
|
||||||
|
background-repeat:no-repeat}
|
||||||
|
.stations{list-style:none;margin:2.6rem 0 0;padding:0 1% 1.6rem;display:grid;grid-template-columns:repeat(12,minmax(0,1fr));align-items:start;
|
||||||
|
color:#cfe3f4;font-weight:550;font-size:.84rem;line-height:1.2;letter-spacing:-.01em;text-align:center}
|
||||||
|
.stations li{padding-inline:.15rem}
|
||||||
|
.stations li:nth-child(even){margin-top:1.7rem}
|
||||||
|
.needle{position:absolute;top:2.35rem;height:2.7rem;left:var(--at);width:3px;margin-left:-1.5px;background:var(--needle);border-radius:2px;
|
||||||
|
box-shadow:0 0 0 1px rgba(12,34,56,.6);animation:tune 2.4s cubic-bezier(.3,.7,.3,1) .4s both}
|
||||||
|
.stations .tuned{text-decoration:underline 2px transparent;text-underline-offset:.3em;animation:lit .5s ease-out 2.6s both}
|
||||||
|
@keyframes tune{0%{left:3%}68%{left:calc(var(--at) + 7%)}86%{left:calc(var(--at) - 1.4%)}100%{left:var(--at)}}
|
||||||
|
@keyframes lit{from{color:#cfe3f4}to{color:#fff;font-weight:800;text-decoration-color:var(--needle)}}
|
||||||
|
@media (prefers-reduced-motion:reduce){.needle,.stations .tuned{animation-duration:0s;animation-delay:0s}}
|
||||||
|
|
||||||
|
h2{margin:0 0 1.5rem;color:var(--navy);font-weight:800;font-size:clamp(1.75rem,4vw,2.4rem);line-height:1.1;letter-spacing:-.02em}
|
||||||
|
.does,.then{padding-block:4rem 0}
|
||||||
|
/* Each feature on one row: what it does on the left, beside the part of the app that shows it,
|
||||||
|
framed in the dial's navy at no more than its own size. */
|
||||||
|
.feature{display:grid;grid-template-columns:minmax(0,22rem) minmax(0,1fr);gap:clamp(1.5rem,5vw,4rem);align-items:center;padding-block:2.5rem}
|
||||||
|
.feature h3{margin:0 0 .5rem;color:var(--navy);font-weight:750;font-size:1.35rem;line-height:1.25}
|
||||||
|
.feature p{margin:0;max-width:34rem}
|
||||||
|
.frame{margin:0;justify-self:start;max-width:100%;padding:clamp(.6rem,1.6vw,1.1rem);background:var(--navy);border-radius:1.1rem}
|
||||||
|
.frame img{display:block;max-width:100%;height:auto;border-radius:.55rem}
|
||||||
|
/* DeadAir: the track is the episode's full length; talk keeps its width, each silence lights
|
||||||
|
in the needle's orange and shrinks to a pause, and the end comes in from the right. */
|
||||||
|
.frame.deadair{justify-self:stretch;width:100%}
|
||||||
|
.wave{display:flex;align-items:center;height:4.5rem;border-radius:.55rem;background:rgba(143,194,234,.08)}
|
||||||
|
.talk{height:100%;background:url(/wave.svg) 0 50%/auto 100% repeat-x}
|
||||||
|
.talk:first-child{border-radius:.55rem 0 0 .55rem}
|
||||||
|
.t1{flex:0 0 14%}.t2{flex:0 0 19%}.t3{flex:0 0 9%}.t4{flex:0 0 16%}.t5{flex:0 0 12%}.t6{flex:0 0 10%}
|
||||||
|
.hush{flex:0 0 4%;align-self:stretch;background:linear-gradient(var(--tick),var(--tick)) 50%/100% 2px no-repeat;border-radius:.3rem;animation:hush 7s ease-in-out infinite}
|
||||||
|
@keyframes hush{
|
||||||
|
0%,14%{flex-basis:4%;background-color:transparent}
|
||||||
|
24%,30%{flex-basis:4%;background-color:rgba(255,85,0,.35)}
|
||||||
|
48%,86%{flex-basis:.8%;background-color:rgba(255,85,0,.35)}
|
||||||
|
100%{flex-basis:4%;background-color:transparent}}
|
||||||
|
@media (prefers-reduced-motion:reduce){.hush{animation:none;flex-basis:.8%;background-color:rgba(255,85,0,.35)}}
|
||||||
|
.also{margin:2rem 0 0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:2.25rem 3.5rem}
|
||||||
|
.also dt{color:var(--navy);font-weight:750;font-size:1.2rem;line-height:1.25}
|
||||||
|
.also dd{margin:.45rem 0 0;max-width:34rem}
|
||||||
|
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
@media (max-width:640px){
|
||||||
|
main,.bar,footer{padding-inline:1rem}
|
||||||
|
.bar nav{gap:1rem}
|
||||||
|
.hero{padding-block:1.5rem 2rem}
|
||||||
|
.feature{grid-template-columns:minmax(0,1fr);gap:1.25rem;padding-block:1.75rem}
|
||||||
|
/* A wide crop at this width is too small to read: its left part, larger. */
|
||||||
|
.frame img.cut{width:100%;aspect-ratio:4/3;object-fit:cover;object-position:0 0}
|
||||||
|
.frame img.cut.player{aspect-ratio:2.4/1}
|
||||||
|
.frame img.cut.half{aspect-ratio:2.6/1} /* the directory's left column */
|
||||||
|
.also{grid-template-columns:minmax(0,1fr);gap:1.75rem}
|
||||||
|
.dial{margin-inline:-1rem}
|
||||||
|
.scale{border-radius:0}
|
||||||
|
/* Six stations, the tuned one third, rather than twelve too narrow to read. */
|
||||||
|
.track{--at:41.7%}
|
||||||
|
.stations{grid-template-columns:repeat(6,minmax(0,1fr));padding-inline:2%}
|
||||||
|
.stations li:nth-child(-n+3),.stations li:nth-child(n+10){display:none}
|
||||||
|
.stations li:nth-child(even){margin-top:0}
|
||||||
|
.stations li:nth-child(odd){margin-top:1.7rem}
|
||||||
|
}
|
||||||
1
site/wave.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="64" viewBox="0 0 48 64" fill="#8fc2ea"><rect x="1" y="22" width="3" height="20" rx="1.5"/><rect x="7" y="10" width="3" height="44" rx="1.5"/><rect x="13" y="17" width="3" height="30" rx="1.5"/><rect x="19" y="3" width="3" height="58" rx="1.5"/><rect x="25" y="14" width="3" height="36" rx="1.5"/><rect x="31" y="7" width="3" height="50" rx="1.5"/><rect x="37" y="20" width="3" height="24" rx="1.5"/><rect x="43" y="12" width="3" height="40" rx="1.5"/></svg>
|
||||||
|
After Width: | Height: | Size: 517 B |
@@ -47,7 +47,7 @@ function makeContext(html, script, { media = false } = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
document, console,
|
document, console, crypto: require("crypto").webcrypto, performance,
|
||||||
window: { isSecureContext: false, addEventListener(){} },
|
window: { isSecureContext: false, addEventListener(){} },
|
||||||
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
|
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
|
||||||
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
|
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
|
||||||
@@ -66,6 +66,7 @@ function makeContext(html, script, { media = false } = {}) {
|
|||||||
EventSource: function () { this.close = () => {}; },
|
EventSource: function () { this.close = () => {}; },
|
||||||
MediaMetadata: function () {},
|
MediaMetadata: function () {},
|
||||||
Blob: function () {},
|
Blob: function () {},
|
||||||
|
getComputedStyle: () => ({ backgroundColor: '' }),
|
||||||
setTimeout, clearTimeout, setInterval, clearInterval,
|
setTimeout, clearTimeout, setInterval, clearInterval,
|
||||||
confirm: () => false, prompt: () => null, alert(){},
|
confirm: () => false, prompt: () => null, alert(){},
|
||||||
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
|
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
|
||||||
|
|||||||
54
tests/load/browse.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep } from 'k6';
|
||||||
|
import { BASE, FEEDS, ITEMS, as, me, pick, feedId, p95 } from './lib.js';
|
||||||
|
|
||||||
|
// An evening's browsing: twenty-five people at once opening their feed list, All Subscriptions,
|
||||||
|
// a feed, a search, the Directory and a feed's page in it. Each answer's budget is well above what
|
||||||
|
// it takes now, so it fails on a query that has started asking once per feed -- the Directory
|
||||||
|
// asked the database three questions a feed until 0.10.0 -- not on a slow minute. Fifty measured
|
||||||
|
// the queue for what was SQLite's one connection (#136) more than any query.
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
evening: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
stages: [{ duration: '15s', target: 25 }, { duration: '45s', target: 25 }, { duration: '10s', target: 0 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
http_req_failed: ['rate==0'],
|
||||||
|
checks: ['rate==1'],
|
||||||
|
// About twice each one's p95 on 2026-10-05 on Tower, and no less than 50ms, where a few ms
|
||||||
|
// either way is noise. SQLite: 89, 17, 10, 22, 112, 126ms; Postgres: 54, 34, 24, 44, 71, 88ms.
|
||||||
|
'http_req_duration{name:feeds}': p95(200, 150),
|
||||||
|
'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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const u = me();
|
||||||
|
const feeds = http.get(`${BASE}/api/feeds`, as(u, 'feeds'));
|
||||||
|
check(feeds, { 'your thirty feeds': r => r.status === 200 && r.json().length === 30 });
|
||||||
|
|
||||||
|
const all = http.get(`${BASE}/api/entries?limit=50&filter=all&sort=published&dir=desc`, as(u, 'all-entries'));
|
||||||
|
check(all, { 'All Subscriptions, a page of it': r => r.status === 200 && r.json().entries.length === 50 });
|
||||||
|
|
||||||
|
const f = pick(feeds.json());
|
||||||
|
const one = http.get(`${BASE}/api/feeds/${f.id}/entries?limit=50&sort=title&dir=asc`, as(u, 'feed-entries'));
|
||||||
|
check(one, { 'a feed of yours, every item': r => r.status === 200 && r.json().entries.length === ITEMS });
|
||||||
|
|
||||||
|
// Every generated item mentions the weather, in its text, not its title.
|
||||||
|
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 });
|
||||||
|
|
||||||
|
sleep(1 + Math.random() * 2);
|
||||||
|
}
|
||||||
19
tests/load/lib.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// What the load tests share. Each virtual user is a listener of its own, signed in by name with
|
||||||
|
// the header the scratch daemon trusts from 127.0.0.1, as production trusts Cloudflare Access's.
|
||||||
|
export const BASE = __ENV.BASE;
|
||||||
|
export const USERS = Number(__ENV.USERS || 100);
|
||||||
|
export const FEEDS = Number(__ENV.FEEDS || 1500);
|
||||||
|
// Each generated feed has this many items; see run.js.
|
||||||
|
export const ITEMS = 20;
|
||||||
|
|
||||||
|
export const as = (name, tag) => ({
|
||||||
|
headers: { 'X-Load-User': name, 'Content-Type': 'application/json' },
|
||||||
|
tags: { name: tag },
|
||||||
|
});
|
||||||
|
/// The listener this virtual user is, one of the hundred run.js seeded, thirty feeds each.
|
||||||
|
export const me = () => `load-${(__VU - 1) % USERS}`;
|
||||||
|
export const pick = a => a[Math.floor(Math.random() * a.length)];
|
||||||
|
/// A p95 budget, in ms, for whichever database the daemon is on (run.js --postgres): SQLite reads
|
||||||
|
/// a file beside the daemon, Postgres answers over the network, and each is quicker at something.
|
||||||
|
export const p95 = (sqlite, postgres) => [`p(95)<${__ENV.DB === 'postgres' ? postgres : sqlite}`];
|
||||||
|
export const feedId = n => `gen-${String(n).padStart(4, '0')}`;
|
||||||
57
tests/load/listening.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep } from 'k6';
|
||||||
|
import { BASE, as, me, pick, p95 } from './lib.js';
|
||||||
|
|
||||||
|
// Players saving where people are while scans write. A hundred listeners each save a position
|
||||||
|
// every second -- the player saves every ten, so this is a thousand people listening -- mark an
|
||||||
|
// item read now and then, and read their position back to see it is theirs and as they left it,
|
||||||
|
// while one of them forces a scan of all their feeds every five seconds. On SQLite every one of
|
||||||
|
// these is a write waiting its turn for the one writer.
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
listeners: { executor: 'constant-vus', vus: 100, duration: '60s', exec: 'listen' },
|
||||||
|
scans: { executor: 'constant-vus', vus: 1, duration: '60s', exec: 'scan' },
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
http_req_failed: ['rate==0'],
|
||||||
|
checks: ['rate==1'],
|
||||||
|
// About twice each one's p95 on 2026-10-05 on Tower. SQLite: 21, 277 and 134ms; Postgres: 9,
|
||||||
|
// 225 and 153ms. Marking read answers with the feed's row, counts and all, hence its budget.
|
||||||
|
'http_req_duration{name:position}': p95(100, 100),
|
||||||
|
'http_req_duration{name:flags}': p95(600, 500),
|
||||||
|
'http_req_duration{name:read-back}': p95(300, 400),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Each virtual user's own episode, and how far into it it is.
|
||||||
|
let ep = null, secs = 0, n = 0;
|
||||||
|
|
||||||
|
export function listen() {
|
||||||
|
const u = me();
|
||||||
|
if (!ep) {
|
||||||
|
const r = http.get(`${BASE}/api/entries?limit=50`, as(u, 'pick'));
|
||||||
|
ep = pick(r.json().entries.filter(e => e.duration));
|
||||||
|
}
|
||||||
|
secs += 1;
|
||||||
|
const saved = http.post(`${BASE}/api/entries/${encodeURIComponent(ep.feed_id)}/${encodeURIComponent(ep.guid)}/position`,
|
||||||
|
JSON.stringify({ secs, duration: ep.duration }), as(u, 'position'));
|
||||||
|
check(saved, { 'position saved': r => r.status === 204 });
|
||||||
|
if (++n % 10 === 0) {
|
||||||
|
const flags = http.post(`${BASE}/api/entries/${encodeURIComponent(ep.feed_id)}/${encodeURIComponent(ep.guid)}/flags`,
|
||||||
|
JSON.stringify({ read: n % 20 === 0 }), as(u, 'flags'));
|
||||||
|
check(flags, { 'marked read or unread': r => r.status === 200 });
|
||||||
|
const back = http.get(`${BASE}/api/feeds/${encodeURIComponent(ep.feed_id)}/entries?limit=50`, as(u, 'read-back'));
|
||||||
|
const mine = back.status === 200 && back.json().entries.find(e => e.guid === ep.guid);
|
||||||
|
check(mine, {
|
||||||
|
'your position, as you left it': e => e && e.position === secs,
|
||||||
|
'read as you marked it': e => e && e.read === (n % 20 === 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scan() {
|
||||||
|
const r = http.post(`${BASE}/api/fetch`, JSON.stringify({ force: true }), as('load-0', 'fetch'));
|
||||||
|
check(r, { 'scan queued': r => r.status === 202 || r.status === 200 });
|
||||||
|
sleep(5);
|
||||||
|
}
|
||||||
48
tests/load/media.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import http from 'k6/http';
|
||||||
|
import { check } from 'k6';
|
||||||
|
import { BASE, as, pick } from './lib.js';
|
||||||
|
|
||||||
|
// Listeners seeking: fifty at once asking for ranges of the same few episodes, as a player does
|
||||||
|
// on every seek and every few seconds of playback. Every range is checked against what the feed
|
||||||
|
// served, byte by byte at a sample of offsets, so a wrong offset fails, not only a wrong status.
|
||||||
|
export const options = {
|
||||||
|
scenarios: { seeking: { executor: 'constant-vus', vus: 50, duration: '45s' } },
|
||||||
|
thresholds: {
|
||||||
|
http_req_failed: ['rate==0'],
|
||||||
|
checks: ['rate==1'],
|
||||||
|
// About four times its p95 on 2026-10-05, 48ms on SQLite and 45 on Postgres: a range is a
|
||||||
|
// file read, little to vary.
|
||||||
|
'http_req_duration{name:range}': ['p(95)<200'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// The generated file: ID3's ten bytes, then a byte its offset gives (run.js, mediaByte).
|
||||||
|
const SIZE = 2 * 1024 * 1024;
|
||||||
|
const byte = k => (k * 31 + 7) & 255;
|
||||||
|
|
||||||
|
export function setup() {
|
||||||
|
const r = http.get(`${BASE}/api/entries?filter=downloaded&limit=50`, as('listener', 'setup'));
|
||||||
|
const files = r.json().entries.flatMap(e => e.enclosures).filter(x => x.path).map(x => x.id);
|
||||||
|
if (!files.length) throw new Error('the listener has no downloaded files to seek through');
|
||||||
|
return { files };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ({ files }) {
|
||||||
|
const start = 10 + Math.floor(Math.random() * (SIZE - 11));
|
||||||
|
const end = Math.min(SIZE - 1, start + Math.floor(Math.random() * 65536));
|
||||||
|
const r = http.get(`${BASE}/media/${pick(files)}`, {
|
||||||
|
headers: { 'X-Load-User': 'listener', Range: `bytes=${start}-${end}` },
|
||||||
|
responseType: 'binary',
|
||||||
|
tags: { name: 'range' },
|
||||||
|
});
|
||||||
|
const body = r.status === 206 ? new Uint8Array(r.body) : new Uint8Array(0);
|
||||||
|
check(r, {
|
||||||
|
'part of the file': r => r.status === 206,
|
||||||
|
'the range asked for': r => r.headers['Content-Range'] === `bytes ${start}-${end}/${SIZE}`,
|
||||||
|
'its bytes': () => {
|
||||||
|
if (body.length !== end - start + 1) return false;
|
||||||
|
for (const at of [0, body.length - 1, ...Array.from({ length: 30 }, () => Math.floor(Math.random() * body.length))])
|
||||||
|
if (body[at] !== byte(start + at)) return false;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
280
tests/load/run.js
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
// Load tests: k6 against a scratch daemon with a catalogue the size of production's, for what
|
||||||
|
// the browser tests cannot show -- many people at once, and what that does to latency, to the
|
||||||
|
// database and to the healthcheck. The browser suite drives one person against a handful of
|
||||||
|
// feeds, one request at a time.
|
||||||
|
//
|
||||||
|
// node tests/load/run.js [--postgres] [browse|listening|media|signin ...] default: all four
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// /src/.envrc), in a schema of its own, ipx_load, made afresh each run (#139). Postgres's numbers
|
||||||
|
// are what production would see.
|
||||||
|
// IPX_LOAD_FEEDS sets the catalogue's size (1500, near production's), IPX_LOAD_USERS the
|
||||||
|
// listeners (100), IPX_LOAD_LOG=1 shows the daemon's log.
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn, spawnSync, execFile, execFileSync } = require('child_process');
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, '../..');
|
||||||
|
const root = '/tmp/ipx-load';
|
||||||
|
const WEB = 8793, GEN = 8794;
|
||||||
|
const FEEDS = Number(process.env.IPX_LOAD_FEEDS || 1500);
|
||||||
|
const USERS = Number(process.env.IPX_LOAD_USERS || 100);
|
||||||
|
// The first few feeds carry real files, downloaded for the media test.
|
||||||
|
const MEDIA = 4;
|
||||||
|
const ITEMS = 20;
|
||||||
|
const BASE = `http://127.0.0.1:${WEB}`;
|
||||||
|
const bin = path.join(repo, 'target/release/ipx');
|
||||||
|
const SCRIPTS = ['browse', 'listening', 'media', 'signin'];
|
||||||
|
const POSTGRES = process.argv.includes('--postgres');
|
||||||
|
const PG_URL = process.env.IPX_TEST_DATABASE_URL || '';
|
||||||
|
const PG_SCHEMA = 'ipx_load';
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
IPX_CONFIG: `${root}/config/config.toml`,
|
||||||
|
IPX_DATA_DIR: `${root}/data`,
|
||||||
|
IPX_LOG: 'ipx=info',
|
||||||
|
IPX_LOG_FORMAT: 'json',
|
||||||
|
// Nothing of the test reaches production's database or traces, or a paid API.
|
||||||
|
// In its own schema, as Db::memory puts each Rust test, and with notices off, as url_for does.
|
||||||
|
IPX_DATABASE_URL: POSTGRES
|
||||||
|
? `${PG_URL}${PG_URL.includes('?') ? '&' : '?'}options=-c%20search_path%3D${PG_SCHEMA}%20-c%20client_min_messages%3Dwarning`
|
||||||
|
: '',
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: '',
|
||||||
|
TYPESAFE_KEY: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- the feeds ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Apple's categories, some with a subcategory, so the Directory has tiles and pages to draw.
|
||||||
|
const CATS = [['Technology'], ['News', 'Tech News'], ['Comedy'], ['Leisure', 'Video Games'],
|
||||||
|
['Society & Culture', 'Documentary'], ['Sports', 'Soccer'], ['Arts', 'Books'],
|
||||||
|
['Science', 'Astronomy'], ['History'], ['True Crime'], ['Business', 'Investing'],
|
||||||
|
['Education', 'Self-Improvement'], ['Health & Fitness', 'Mental Health'], ['Music']];
|
||||||
|
const esc = s => s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||||
|
const now = Date.now();
|
||||||
|
// Every third feed is a blog: no files, as most of production's are.
|
||||||
|
const isBlog = n => n % 3 === 2 && n >= MEDIA;
|
||||||
|
function feedXml(n) {
|
||||||
|
const [cat, sub] = CATS[n % CATS.length];
|
||||||
|
const category = sub
|
||||||
|
? `<itunes:category text="${esc(cat)}"><itunes:category text="${esc(sub)}"/></itunes:category>`
|
||||||
|
: `<itunes:category text="${esc(cat)}"/>`;
|
||||||
|
const items = Array.from({ length: ITEMS }, (_, i) => `<item>
|
||||||
|
<title>Episode ${ITEMS - i} of Show ${n}</title><guid>gen-${n}-${i}</guid>
|
||||||
|
<pubDate>${new Date(now - (i * 3 + n % 3) * 86400e3).toUTCString()}</pubDate>
|
||||||
|
<description><p>Show ${n}, episode ${ITEMS - i}: an hour on the news, the weather and whatever came up.</p></description>
|
||||||
|
${isBlog(n) ? '' : `<itunes:duration>${1800 + i * 60}</itunes:duration>
|
||||||
|
<enclosure url="http://127.0.0.1:${GEN}/media/${n}/${i}.mp3" length="${MEDIA_BYTES.length}" type="audio/mpeg"/>`}
|
||||||
|
</item>`).join('');
|
||||||
|
return `<?xml version="1.0"?><rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"><channel>
|
||||||
|
<title>Generated Show ${n}</title><link>http://127.0.0.1:${GEN}/site/${n}</link>
|
||||||
|
<description>Generated show number ${n}, for the load tests.</description>
|
||||||
|
<itunes:image href="http://127.0.0.1:${GEN}/art/${n}.jpg"/>${category}${items}</channel></rss>`;
|
||||||
|
}
|
||||||
|
// A file whose every byte is known from its offset, so the media test can check that a range
|
||||||
|
// it asked for is the range it got, not merely that it got something that long. ID3 first, so
|
||||||
|
// ipx takes it for audio.
|
||||||
|
const mediaByte = k => (k * 31 + 7) & 255;
|
||||||
|
const MEDIA_BYTES = Buffer.from(Uint8Array.from({ length: 2 * 1024 * 1024 }, (_, k) => mediaByte(k)));
|
||||||
|
Buffer.from('ID3\x03\x00\x00\x00\x00\x00\x00', 'latin1').copy(MEDIA_BYTES);
|
||||||
|
const art = fs.readFileSync(path.join(repo, 'tests/ui/fixtures/art.jpg'));
|
||||||
|
|
||||||
|
function serveFeeds() {
|
||||||
|
return http.createServer((req, res) => {
|
||||||
|
const m = req.url.match(/^\/(feed|media|art)\/(\d+)/);
|
||||||
|
if (m?.[1] === 'feed') { res.writeHead(200, { 'content-type': 'application/rss+xml' }); return res.end(feedXml(Number(m[2]))); }
|
||||||
|
if (m?.[1] === 'media') { res.writeHead(200, { 'content-type': 'audio/mpeg', 'content-length': MEDIA_BYTES.length }); return res.end(MEDIA_BYTES); }
|
||||||
|
if (m?.[1] === 'art') { res.writeHead(200, { 'content-type': 'image/jpeg' }); return res.end(art); }
|
||||||
|
res.writeHead(404).end();
|
||||||
|
}).listen(GEN, '127.0.0.1');
|
||||||
|
}
|
||||||
|
const feedId = n => `gen-${String(n).padStart(4, '0')}`;
|
||||||
|
const feedUrl = n => `http://127.0.0.1:${GEN}/feed/${n}.xml`;
|
||||||
|
|
||||||
|
// ---- the daemon -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The load tests' schema dropped and made again, as /tmp/ipx-load is wiped. Only in a database
|
||||||
|
/// made for tests: the URL is production's server and role, and one slip of a name would
|
||||||
|
/// otherwise point this at production's database.
|
||||||
|
function freshPostgres() {
|
||||||
|
const u = PG_URL && new URL(PG_URL);
|
||||||
|
if (!u || !/_test$/.test(u.pathname)) {
|
||||||
|
throw new Error('--postgres needs IPX_TEST_DATABASE_URL, a database whose name ends in _test (. /src/.envrc)');
|
||||||
|
}
|
||||||
|
psql(`DROP SCHEMA IF EXISTS ${PG_SCHEMA} CASCADE`, `CREATE SCHEMA ${PG_SCHEMA}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statements run in the test database, given by its URL in psql's environment, not its
|
||||||
|
/// arguments, where every process on Tower could read the password.
|
||||||
|
function psql(...sql) {
|
||||||
|
const u = new URL(PG_URL);
|
||||||
|
const env = { ...process.env, PGHOST: u.hostname, PGPORT: u.port || '5432', PGUSER: decodeURIComponent(u.username),
|
||||||
|
PGPASSWORD: decodeURIComponent(u.password), PGDATABASE: u.pathname.slice(1), PGOPTIONS: `-c search_path=${PG_SCHEMA}` };
|
||||||
|
execFileSync('psql', ['-q', '-v', 'ON_ERROR_STOP=1', ...sql.flatMap(s => ['-c', s])], { env, stdio: ['ignore', 'ignore', 'inherit'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeConfig() {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
for (const d of ['config', 'data', 'downloads']) fs.mkdirSync(path.join(root, d), { recursive: true });
|
||||||
|
// Read into the database's catalogue on the first start, as an existing config.toml is. Only
|
||||||
|
// the media feeds download: a forced scan of a listener's 30 feeds would otherwise fetch a
|
||||||
|
// 2 MB episode of each, every time.
|
||||||
|
const feeds = Array.from({ length: FEEDS }, (_, n) =>
|
||||||
|
`[feeds.${feedId(n)}]\nurl = "${feedUrl(n)}"\nauto_download = ${n < MEDIA}\n`).join('\n');
|
||||||
|
fs.writeFileSync(env.IPX_CONFIG, `
|
||||||
|
[general]
|
||||||
|
download_dir = "${root}/downloads"
|
||||||
|
socket = "${root}/ipx.sock"
|
||||||
|
schedule = "every 60m"
|
||||||
|
max_new_per_check = 1
|
||||||
|
|
||||||
|
[torrent]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
[web]
|
||||||
|
enabled = true
|
||||||
|
bind = "127.0.0.1:${WEB}"
|
||||||
|
token = "loadtokenloadtokenloadtoken12345"
|
||||||
|
# Each k6 user signs in by name, as Cloudflare Access does in production: no password to hash
|
||||||
|
# for every one of a hundred listeners. Only the sign-in test uses a password.
|
||||||
|
trusted_header = "X-Load-User"
|
||||||
|
trusted_proxies = ["127.0.0.1"]
|
||||||
|
auto_create_users = true
|
||||||
|
|
||||||
|
${feeds}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves with the first log line matching `test`, read from the daemon's JSON log, which it
|
||||||
|
/// writes to stderr.
|
||||||
|
function waitForLog(daemon, test, ms) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buf = '';
|
||||||
|
const timer = setTimeout(() => { daemon.stderr.off('data', on); reject(new Error(`no log line in ${ms / 1000}s for ${test}`)); }, ms);
|
||||||
|
const on = chunk => {
|
||||||
|
buf += chunk;
|
||||||
|
let i;
|
||||||
|
while ((i = buf.indexOf('\n')) >= 0) {
|
||||||
|
const line = buf.slice(0, i); buf = buf.slice(i + 1);
|
||||||
|
let ev; try { ev = JSON.parse(line); } catch { continue; }
|
||||||
|
if (test(ev)) { clearTimeout(timer); daemon.stderr.off('data', on); return resolve(ev); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
daemon.stderr.on('data', on);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const as = name => ({ 'X-Load-User': name, 'Content-Type': 'application/json' });
|
||||||
|
async function api(name, url, opts = {}) {
|
||||||
|
const r = await fetch(BASE + url, { ...opts, headers: { ...as(name), ...opts.headers } });
|
||||||
|
if (!r.ok) throw new Error(`${opts.method || 'GET'} ${url} as ${name}: ${r.status} ${await r.text()}`);
|
||||||
|
return r.status === 204 ? null : r.json().catch(() => null);
|
||||||
|
}
|
||||||
|
const opml = ns => `<?xml version="1.0"?><opml version="2.0"><head><title>load</title></head><body>${
|
||||||
|
ns.map(n => `<outline type="rss" text="${feedId(n)}" xmlUrl="${feedUrl(n)}"/>`).join('')}</body></opml>`;
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
// The first account made is the admin.
|
||||||
|
await api('admin', '/api/me');
|
||||||
|
// Every listener subscribes to 30 feeds, overlapping as people's do; one OPML each, one scan.
|
||||||
|
for (let u = 0; u < USERS; u++) {
|
||||||
|
const mine = Array.from({ length: 30 }, (_, k) => (u * 7 + k * 41) % FEEDS);
|
||||||
|
await api(`load-${u}`, '/api/opml', { method: 'POST', body: JSON.stringify({ xml: opml(mine) }) });
|
||||||
|
}
|
||||||
|
// 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' });
|
||||||
|
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;
|
||||||
|
if (Date.now() - t > 120_000) throw new Error(`the listener's ${MEDIA} files did not download in 120s`);
|
||||||
|
}
|
||||||
|
// A password, for the sign-in test; the CLI hashes it as `ipx user add` always does.
|
||||||
|
execFileSync(bin, ['user', 'add', 'piper'], { input: 'piperpassword', env });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the run --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `ipx status` once a second until stopped: the slowest answer, and any that failed. Not
|
||||||
|
/// spawnSync: blocked in it, this process stops draining the daemon's log, the pipe fills, and
|
||||||
|
/// the daemon stalls writing its next line, which is the test measuring itself.
|
||||||
|
function watchHealth() {
|
||||||
|
const seen = { slowest: 0, failed: 0 };
|
||||||
|
let on = true;
|
||||||
|
(async () => {
|
||||||
|
while (on) {
|
||||||
|
const t = Date.now();
|
||||||
|
const ok = await new Promise(res => execFile(bin, ['status'], { env, timeout: 5000 }, err => res(!err)));
|
||||||
|
seen.slowest = Math.max(seen.slowest, Date.now() - t);
|
||||||
|
if (!ok) seen.failed++;
|
||||||
|
await new Promise(res => setTimeout(res, 1000));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { on = false; return seen; };
|
||||||
|
}
|
||||||
|
|
||||||
|
function k6(script) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const run = spawn('k6', ['run', '--quiet', '-e', `BASE=${BASE}`, '-e', `USERS=${USERS}`, '-e', `FEEDS=${FEEDS}`,
|
||||||
|
'-e', `DB=${POSTGRES ? 'postgres' : 'sqlite'}`,
|
||||||
|
path.join(__dirname, `${script}.js`)], { stdio: 'inherit' });
|
||||||
|
run.on('exit', code => resolve(code));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const only = process.argv.slice(2).filter(a => a !== '--postgres');
|
||||||
|
for (const s of only) if (!SCRIPTS.includes(s)) { console.error(`no load test called ${s}: ${SCRIPTS.join(', ')}`); process.exit(2); }
|
||||||
|
if (spawnSync('k6', ['version']).error) { console.error('k6 is not installed: see install.sh'); process.exit(2); }
|
||||||
|
console.log('building the release binary...');
|
||||||
|
execFileSync('cargo', ['build', '--release', '-q'], { cwd: repo, stdio: 'inherit' });
|
||||||
|
|
||||||
|
const feeds = serveFeeds();
|
||||||
|
writeConfig();
|
||||||
|
if (POSTGRES) freshPostgres();
|
||||||
|
console.log(`on ${POSTGRES ? 'Postgres' : 'SQLite'}`);
|
||||||
|
// Its log kept out of this run's output, which is k6's; IPX_LOAD_LOG=1 shows it.
|
||||||
|
const daemon = spawn(bin, ['daemon'], { env, stdio: ['ignore', process.env.IPX_LOAD_LOG ? 'inherit' : 'ignore', 'pipe'] });
|
||||||
|
daemon.stderr.setEncoding('utf8');
|
||||||
|
if (process.env.IPX_LOAD_LOG) daemon.stderr.on('data', d => process.stderr.write(d));
|
||||||
|
// Stopped by hand, the daemon goes too, by its own PID, or it holds the ports for the next run.
|
||||||
|
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { daemon.kill('SIGTERM'); process.exit(130); });
|
||||||
|
let failed = 0;
|
||||||
|
try {
|
||||||
|
const t = Date.now();
|
||||||
|
const first = await waitForLog(daemon, ev => ev.ev === 'scan_done' && ev.feeds > 0, 600_000);
|
||||||
|
console.log(`first scan: ${first.feeds} feeds in ${((Date.now() - t) / 1000).toFixed(1)}s`);
|
||||||
|
await seed();
|
||||||
|
// The planner's statistics, as production's long-standing tables have them. A schema filled
|
||||||
|
// seconds ago has none until autovacuum gets to it, which it did partway through a test, and
|
||||||
|
// the search's p95 swung from 40ms to 166ms between runs with the plan it changed.
|
||||||
|
if (POSTGRES) psql('ANALYZE');
|
||||||
|
// The log is drained from here on, or the pipe fills and the daemon blocks writing to it.
|
||||||
|
daemon.stderr.resume();
|
||||||
|
for (const script of only.length ? only : SCRIPTS) {
|
||||||
|
console.log(`\n=== ${script}`);
|
||||||
|
const stop = watchHealth();
|
||||||
|
const code = await k6(script);
|
||||||
|
const health = stop();
|
||||||
|
console.log(`ipx status: slowest ${health.slowest}ms, ${health.failed} failed`);
|
||||||
|
if (code !== 0) { failed++; console.log(`${script}: thresholds failed (k6 exit ${code})`); }
|
||||||
|
if (health.failed || health.slowest >= 5000) { failed++; console.log(`${script}: the healthcheck would have failed`); }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
failed++;
|
||||||
|
} finally {
|
||||||
|
// Its own PID, never a pkill: Tower sees production's ipx too (#38).
|
||||||
|
daemon.kill('SIGTERM');
|
||||||
|
feeds.close();
|
||||||
|
}
|
||||||
|
console.log(failed ? `\n${failed} failure(s)` : '\nall load tests passed');
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
|
})();
|
||||||
44
tests/load/signin.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep } from 'k6';
|
||||||
|
import { Trend } from 'k6/metrics';
|
||||||
|
import { BASE, as, me } from './lib.js';
|
||||||
|
|
||||||
|
// A flood of sign-in attempts with wrong passwords while everyone else goes on using the site.
|
||||||
|
// Two things only many requests at once show: whether checking passwords, tens of milliseconds
|
||||||
|
// of CPU each, starves the rest of the server, and whether a wrong password for a name that is
|
||||||
|
// an account takes longer to refuse than one for a name that is not -- the answers read the
|
||||||
|
// same, and the time would tell anyone which names are accounts here.
|
||||||
|
const gap = new Trend('signin_name_gap', true);
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
attempts: { executor: 'constant-vus', vus: 40, duration: '40s', exec: 'attempt' },
|
||||||
|
everyone: { executor: 'constant-vus', vus: 5, duration: '40s', exec: 'browse' },
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
http_req_failed: ['rate==0'],
|
||||||
|
checks: ['rate==1'],
|
||||||
|
// 57ms on 2026-10-05 on SQLite, 68 on Postgres; 4.3s while password checks ran on the async
|
||||||
|
// workers (#137).
|
||||||
|
'http_req_duration{name:meanwhile}': ['p(95)<300'],
|
||||||
|
// Known name against unknown, one straight after the other: no gap but noise. 0.2ms on
|
||||||
|
// 2026-10-05; 31ms while an unknown name was refused without a check (#138).
|
||||||
|
signin_name_gap: ['med<10'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const refused = { headers: { 'Content-Type': 'application/json' }, responseCallback: http.expectedStatuses(401) };
|
||||||
|
|
||||||
|
export function attempt() {
|
||||||
|
const known = http.post(`${BASE}/api/login`, JSON.stringify({ name: 'piper', password: 'not-the-password' }),
|
||||||
|
{ ...refused, tags: { name: 'signin-known' } });
|
||||||
|
const unknown = http.post(`${BASE}/api/login`, JSON.stringify({ name: `nobody-${__VU}-${__ITER}`, password: 'not-the-password' }),
|
||||||
|
{ ...refused, tags: { name: 'signin-unknown' } });
|
||||||
|
check(known, { 'a wrong password refused': r => r.status === 401 });
|
||||||
|
check(unknown, { 'an unknown name refused': r => r.status === 401 });
|
||||||
|
gap.add(known.timings.duration - unknown.timings.duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function browse() {
|
||||||
|
const r = http.get(`${BASE}/api/feeds`, as(me(), 'meanwhile'));
|
||||||
|
check(r, { 'the site still answers': r => r.status === 200 });
|
||||||
|
sleep(0.5);
|
||||||
|
}
|
||||||
@@ -49,7 +49,6 @@ const drive = PAGE === 'admin.html' ? [
|
|||||||
['prefsModal', () => ctx.prefsModal()],
|
['prefsModal', () => ctx.prefsModal()],
|
||||||
['opmlModal', () => ctx.opmlModal()],
|
['opmlModal', () => ctx.opmlModal()],
|
||||||
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
|
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
|
||||||
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
|
|
||||||
['selectFeed (currently listening)', () => ctx.selectFeed(':listening')],
|
['selectFeed (currently listening)', () => ctx.selectFeed(':listening')],
|
||||||
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
|
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
|
||||||
['keysModal', () => ctx.keysModal()],
|
['keysModal', () => ctx.keysModal()],
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ test('Settings picks a theme and, where it has both, light, dark or Auto', async
|
|||||||
// Auto follows the system, live, with no reload.
|
// Auto follows the system, live, with no reload.
|
||||||
await page.emulateMedia({ colorScheme: 'light' });
|
await page.emulateMedia({ colorScheme: 'light' });
|
||||||
await expect.poll(root).toEqual(['modern', 'light']);
|
await expect.poll(root).toEqual(['modern', 'light']);
|
||||||
await expect.poll(bg).toBe('rgb(242, 244, 247)'); // Modern's light --bg
|
await expect.poll(bg).toBe('rgb(238, 245, 251)'); // Modern's light --bg
|
||||||
await page.emulateMedia({ colorScheme: 'dark' });
|
await page.emulateMedia({ colorScheme: 'dark' });
|
||||||
await expect.poll(root).toEqual(['modern', 'dark']);
|
await expect.poll(root).toEqual(['modern', 'dark']);
|
||||||
await expect.poll(bg).toBe('rgb(14, 19, 27)'); // Modern's dark --bg
|
await expect.poll(bg).toBe('rgb(10, 23, 38)'); // Modern's dark --bg
|
||||||
|
|
||||||
// Dracula, then its light half, Alucard.
|
// Dracula, then its light half, Alucard.
|
||||||
await page.locator('#stheme').selectOption('dracula');
|
await page.locator('#stheme').selectOption('dracula');
|
||||||
@@ -63,13 +63,9 @@ test('Settings picks a theme and, where it has both, light, dark or Auto', async
|
|||||||
await page.locator('#stheme').selectOption('paper');
|
await page.locator('#stheme').selectOption('paper');
|
||||||
await expect(page.locator('#smode')).toBeHidden();
|
await expect(page.locator('#smode')).toBeHidden();
|
||||||
await expect.poll(bg).toBe('rgb(242, 238, 222)'); // #F2EEDE
|
await expect.poll(bg).toBe('rgb(242, 238, 222)'); // #F2EEDE
|
||||||
// The save that says Classic: the ones before it may still be answering.
|
|
||||||
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH'
|
|
||||||
&& r.request().postDataJSON().theme === 'classic');
|
|
||||||
await page.locator('#stheme').selectOption('classic');
|
await page.locator('#stheme').selectOption('classic');
|
||||||
await expect(page.locator('#smode')).toBeHidden();
|
await expect(page.locator('#smode')).toBeHidden();
|
||||||
await saved; // kept on the account, not the browser
|
await page.reload(); // kept in this browser's cookie
|
||||||
await page.reload();
|
|
||||||
await expect.poll(root).toEqual(['classic', 'light']);
|
await expect.poll(root).toEqual(['classic', 'light']);
|
||||||
// The 2004 Mac app set its type in Lucida Grande.
|
// The 2004 Mac app set its type in Lucida Grande.
|
||||||
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
|
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
|
||||||
@@ -83,46 +79,44 @@ test('Settings picks a theme and, where it has both, light, dark or Auto', async
|
|||||||
await expect.poll(bg).toBe('rgb(46, 52, 64)'); // nord0
|
await expect.poll(bg).toBe('rgb(46, 52, 64)'); // nord0
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the theme is kept on the account, and follows it to another browser', async ({ page, browser }) => {
|
test('each browser keeps its own theme, in a cookie, not on the account', async ({ page, browser }) => {
|
||||||
|
// Glass on a phone, Dracula on a desktop, signed in as the same person (issue #69).
|
||||||
|
let patched = false;
|
||||||
|
page.on('request', r => { if (r.url().endsWith('/api/me') && r.method() === 'PATCH') patched = true; });
|
||||||
await page.locator('#prefs').click();
|
await page.locator('#prefs').click();
|
||||||
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
|
|
||||||
await page.locator('#stheme').selectOption('flatremix');
|
await page.locator('#stheme').selectOption('flatremix');
|
||||||
expect((await saved).status()).toBe(204);
|
|
||||||
const saved2 = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
|
|
||||||
await page.locator('#smode').selectOption('light');
|
await page.locator('#smode').selectOption('light');
|
||||||
await saved2;
|
const cookie = (await page.context().cookies()).find(c => c.name === 'ipx_theme');
|
||||||
|
expect(cookie?.value).toBe('flatremix.light');
|
||||||
|
expect(patched, 'nothing sent to the account').toBe(false);
|
||||||
|
|
||||||
// Another browser: nothing in its localStorage, and the page still arrives in the theme,
|
// This browser: boot.js reads the cookie in <head> and draws the page in it from the first
|
||||||
// written onto <html> by the server rather than set once the script has run.
|
// frame, before the app's script has run (#203).
|
||||||
|
const firstFrame = () => document.documentElement.dataset;
|
||||||
|
await page.reload({ waitUntil: 'commit' });
|
||||||
|
await page.waitForFunction(() => document.body);
|
||||||
|
expect(await page.evaluate(firstFrame)).toMatchObject({ theme: 'flatremix', choice: 'light', mode: 'light' });
|
||||||
|
|
||||||
|
// Another browser, the same account: not this one's theme.
|
||||||
const other = await browser.newContext();
|
const other = await browser.newContext();
|
||||||
const p2 = await other.newPage();
|
const p2 = await other.newPage();
|
||||||
const res = await p2.goto(`/?token=${TOKEN}`);
|
await p2.goto(`/?token=${TOKEN}`);
|
||||||
expect(await res.text()).toContain('data-theme=flatremix data-choice=light data-mode=light');
|
await expect(p2.locator('#feedlist')).toBeVisible();
|
||||||
expect(await p2.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
|
expect((await p2.evaluate(firstFrame)).theme).not.toBe('flatremix');
|
||||||
.toEqual(['flatremix', 'light']);
|
|
||||||
await other.close();
|
await other.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a theme this browser kept before themes were on the account goes up to it once', async ({ browser }) => {
|
test('a theme this browser kept in localStorage becomes its cookie once', async ({ browser }) => {
|
||||||
// A new account, made by the proxy header on first sight, so it has no theme of its own yet.
|
|
||||||
const who = `theme-${Date.now()}@example.com`;
|
const who = `theme-${Date.now()}@example.com`;
|
||||||
const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
|
const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
|
||||||
// From before light and dark: ipx.theme alone, 'light' meaning Modern, light.
|
// From before light and dark: ipx.theme alone, 'light' meaning Modern, light.
|
||||||
await ctx.addInitScript(() => { localStorage.setItem('ipx.theme', 'light'); localStorage.removeItem('ipx.mode'); });
|
await ctx.addInitScript(() => { localStorage.setItem('ipx.theme', 'light'); localStorage.removeItem('ipx.mode'); });
|
||||||
const page = await ctx.newPage();
|
const page = await ctx.newPage();
|
||||||
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
expect(await page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
|
expect(await page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
|
||||||
.toEqual(['modern', 'light']);
|
.toEqual(['modern', 'light']);
|
||||||
expect((await saved).request().postDataJSON()).toEqual({ theme: 'modern', mode: 'light' });
|
expect((await ctx.cookies()).find(c => c.name === 'ipx_theme')?.value).toBe('modern.light');
|
||||||
await ctx.close();
|
await ctx.close();
|
||||||
|
|
||||||
// Anywhere else now, it comes from the account.
|
|
||||||
const fresh = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
|
|
||||||
const p2 = await fresh.newPage();
|
|
||||||
const res = await p2.goto('/');
|
|
||||||
expect(await res.text()).toContain('data-theme=modern data-choice=light');
|
|
||||||
await fresh.close();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the admin page saves the global schedule', async ({ page }) => {
|
test('the admin page saves the global schedule', async ({ page }) => {
|
||||||
@@ -167,7 +161,7 @@ test('the three panes are there and the item text lands in the bottom one', asyn
|
|||||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
await expect(page.locator('#list')).toBeVisible();
|
await expect(page.locator('#list')).toBeVisible();
|
||||||
await expect(page.locator('#grab')).toBeVisible(); // the draggable divider
|
await expect(page.locator('#grab')).toBeVisible(); // the draggable divider
|
||||||
await expect(page.locator('#detail')).toContainText('Pick an item');
|
await expect(page.locator('#detail .dt')).toHaveCount(0); // nothing picked, nothing said
|
||||||
|
|
||||||
await page.locator('.ep', { hasText: 'First Episode' }).click();
|
await page.locator('.ep', { hasText: 'First Episode' }).click();
|
||||||
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
|
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
|
||||||
@@ -252,7 +246,7 @@ test('an item with several enclosures lists them all', async ({ page }) => {
|
|||||||
await expect(page.locator('#files .encbox').nth(1).locator('.kind[title^="image"]')).toBeVisible();
|
await expect(page.locator('#files .encbox').nth(1).locator('.kind[title^="image"]')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Currently Listening, its own place below Popular, resumes an episode you started or forgets it', async ({ page }) => {
|
test('Currently Listening, its own place below the Directory, resumes an episode you started or forgets it', async ({ page }) => {
|
||||||
// Second Episode (900s) is 42 seconds in and unfinished. An earlier test may have opened it,
|
// Second Episode (900s) is 42 seconds in and unfinished. An earlier test may have opened it,
|
||||||
// and opening marks it read; it is listed all the same, because read is not finished. This
|
// and opening marks it read; it is listed all the same, because read is not finished. This
|
||||||
// test used to set it unread first, which hid exactly the bug in issue #14.
|
// test used to set it unread first, which hid exactly the bug in issue #14.
|
||||||
@@ -261,18 +255,27 @@ test('Currently Listening, its own place below Popular, resumes an episode you s
|
|||||||
await page.evaluate(() =>
|
await page.evaluate(() =>
|
||||||
api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) }));
|
api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) }));
|
||||||
|
|
||||||
// Popular lists feeds and nothing else; the episodes have a place of their own under it.
|
// The Directory lists feeds and nothing else; the episodes have a place of their own under it.
|
||||||
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
await page.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
||||||
await expect(page.locator('#popular')).toBeVisible();
|
await expect(page.locator('#directory')).toBeVisible();
|
||||||
await expect(page.locator('#listening')).toHaveCount(0);
|
await expect(page.locator('#listening')).toHaveCount(0);
|
||||||
const places = await page.locator('#feedlist .place b').allTextContents();
|
const places = await page.locator('#feedlist .place b').allTextContents();
|
||||||
expect(places.indexOf('Currently Listening')).toBe(places.indexOf('Popular') + 1);
|
expect(places.indexOf('Currently Listening')).toBe(places.indexOf('Directory') + 1);
|
||||||
|
|
||||||
await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).click();
|
await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).click();
|
||||||
const row = page.locator('#listening .childrow', { hasText: 'Second Episode' });
|
const row = page.locator('#listening .childrow', { hasText: 'Second Episode' });
|
||||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||||
await expect(row).toContainText('14:18 left');
|
await expect(row).toContainText('14:18 left');
|
||||||
|
|
||||||
|
// The search box looks through it, as it does a feed's items (#127).
|
||||||
|
await page.locator('#epSearch').fill('second');
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await page.locator('#epSearch').fill('nothing-is-called-this');
|
||||||
|
await expect(row).toHaveCount(0);
|
||||||
|
await expect(page.locator('#listening')).toContainText('Nothing you are listening to mentions');
|
||||||
|
await page.locator('#epSearch').fill('');
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
// Removing it forgets where you got to, so it is still gone on the next visit.
|
// Removing it forgets where you got to, so it is still gone on the next visit.
|
||||||
await row.locator('[data-a=remove]').click();
|
await row.locator('[data-a=remove]').click();
|
||||||
await expect(row).toHaveCount(0);
|
await expect(row).toHaveCount(0);
|
||||||
@@ -470,6 +473,11 @@ test('marking an OPML subscription read covers the feeds inside it', async ({ pa
|
|||||||
test.describe('on a phone', () => {
|
test.describe('on a phone', () => {
|
||||||
test.use({ viewport: { width: 390, height: 844 } });
|
test.use({ viewport: { width: 390, height: 844 } });
|
||||||
|
|
||||||
|
test('the top bar leaves the logo out, for the search box', async ({ page }) => {
|
||||||
|
await expect(page.locator('#burger')).toBeVisible();
|
||||||
|
await expect(page.locator('#applogo')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
test('the feed list is reachable and an item reads full screen', async ({ page }) => {
|
test('the feed list is reachable and an item reads full screen', async ({ page }) => {
|
||||||
// The burger used to live in the player bar, which is hidden until something plays --
|
// The burger used to live in the player bar, which is hidden until something plays --
|
||||||
// leaving no way to reach the feeds at all.
|
// leaving no way to reach the feeds at all.
|
||||||
@@ -559,9 +567,11 @@ test('a second person has their own feeds and their own read state', async ({ br
|
|||||||
const page = await ctx.newPage();
|
const page = await ctx.newPage();
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
// The sign-in page shows the logo, so it has to load before anyone has signed in.
|
// The sign-in page shows the logo, so it has to load before anyone has signed in.
|
||||||
const logo = await page.request.get('/logo.svg');
|
for (const path of ['/logo.svg', '/logo-dark.svg']) {
|
||||||
expect(logo.status()).toBe(200);
|
const logo = await page.request.get(path);
|
||||||
expect(logo.headers()['content-type']).toBe('image/svg+xml');
|
expect(logo.status(), path).toBe(200);
|
||||||
|
expect(logo.headers()['content-type'], path).toBe('image/svg+xml');
|
||||||
|
}
|
||||||
await page.locator('#name').fill('sam');
|
await page.locator('#name').fill('sam');
|
||||||
await page.locator('#pw').fill('sampassword');
|
await page.locator('#pw').fill('sampassword');
|
||||||
await page.locator('button[type=submit]').click();
|
await page.locator('button[type=submit]').click();
|
||||||
@@ -577,8 +587,9 @@ test('a second person has their own feeds and their own read state', async ({ br
|
|||||||
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
|
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
|
||||||
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
|
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
|
||||||
await page.locator('#modalCard .cardx').click();
|
await page.locator('#modalCard .cardx').click();
|
||||||
await expect(page.locator('#admin')).toHaveCount(0);
|
// The page is the same for everyone now (#203): the link is there, and hidden from anyone /api/me
|
||||||
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin');
|
// does not call an admin.
|
||||||
|
await expect(page.locator('#admin')).toBeHidden();
|
||||||
// Asking for it anyway goes back to the app, and its script is refused.
|
// Asking for it anyway goes back to the app, and its script is refused.
|
||||||
await page.goto('/admin');
|
await page.goto('/admin');
|
||||||
await expect(page).toHaveURL(/:8791\/$/);
|
await expect(page).toHaveURL(/:8791\/$/);
|
||||||
@@ -815,7 +826,7 @@ test('ipx import subscribes the admin, and ipx export writes the feeds out', asy
|
|||||||
expect(xml).toContain('http://127.0.0.1:8792/two.xml');
|
expect(xml).toContain('http://127.0.0.1:8792/two.xml');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Popular lists what everyone here reads, but never a private feed', async ({ browser }) => {
|
test('the Directory lists what servers share, the most subscribed first, but never a private feed', async ({ browser, page }) => {
|
||||||
const { execFileSync } = require('child_process');
|
const { execFileSync } = require('child_process');
|
||||||
const setup = require('./global-setup');
|
const setup = require('./global-setup');
|
||||||
const env = {
|
const env = {
|
||||||
@@ -836,80 +847,134 @@ test('Popular lists what everyone here reads, but never a private feed', async (
|
|||||||
await piper.locator('button[type=submit]').click();
|
await piper.locator('button[type=submit]').click();
|
||||||
await expect(piper.locator('#feedlist')).toContainText('No feeds.');
|
await expect(piper.locator('#feedlist')).toContainText('No feeds.');
|
||||||
|
|
||||||
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
// The most subscribed are the Directory's first section; Popular, their own place, is gone (#124).
|
||||||
const offered = piper.locator('#popular .childrow');
|
await expect(piper.locator('#feedlist .place', { hasText: 'Popular' })).toHaveCount(0);
|
||||||
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
||||||
|
const chart = piper.locator('#dchart .rank');
|
||||||
|
await expect(chart.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
||||||
// An OPML is listed as the feeds inside it, and a key in a URL marks someone's paid feed.
|
// An OPML is listed as the feeds inside it, and a key in a URL marks someone's paid feed.
|
||||||
await expect(offered.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
|
await expect(chart.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
|
||||||
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
|
await expect(chart.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
|
||||||
|
|
||||||
// No URL reaches the page at all, so neither can a key, and the server holds the same line.
|
// No URL reaches the page at all, so neither can a key, and the server holds the same line.
|
||||||
const listed = await (await piper.request.get('/api/popular')).text();
|
const listed = await (await piper.request.get('/api/directory')).text();
|
||||||
expect(listed).not.toContain('secret123');
|
expect(listed).not.toContain('secret123');
|
||||||
expect(listed).not.toContain('.xml');
|
expect(listed).not.toContain('.xml');
|
||||||
expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400);
|
expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400);
|
||||||
|
|
||||||
// Popular is the top ten of the directory, and the directory is every listed feed A to Z,
|
// The chart is /api/popular's top ten, and the directory is every listed feed A to Z, with an
|
||||||
// with an OPML's feeds in place of the OPML in both.
|
// OPML's feeds in place of the OPML in both.
|
||||||
const dir = await (await piper.request.get('/api/directory')).json();
|
const dir = await (await piper.request.get('/api/directory')).json();
|
||||||
const top = await (await piper.request.get('/api/popular')).json();
|
const top = await (await piper.request.get('/api/popular')).json();
|
||||||
const names = dir.map(p => (p.title || p.id).toLowerCase());
|
const names = dir.map(p => (p.title || p.id).toLowerCase());
|
||||||
expect(names).toEqual([...names].sort());
|
expect(names).toEqual([...names].sort());
|
||||||
|
// The directory's ids, not this server's: it names a feed by its address (#205).
|
||||||
const ids = dir.map(p => p.id);
|
const ids = dir.map(p => p.id);
|
||||||
expect(ids).not.toContain('test-subscriptions');
|
const titles = dir.map(p => p.title);
|
||||||
expect(ids).toEqual(expect.arrayContaining(['grouped-show', 'aardvark-radio']));
|
const byTitle = t => dir.find(p => p.title === t);
|
||||||
expect(top.length).toBe(Math.min(10, dir.length));
|
expect(titles).not.toContain('Test Subscriptions');
|
||||||
|
expect(titles).toEqual(expect.arrayContaining(['Grouped Show', 'Aardvark Radio']));
|
||||||
expect(top.every(t => ids.includes(t.id))).toBe(true);
|
expect(top.every(t => ids.includes(t.id))).toBe(true);
|
||||||
expect(ids).not.toContain('paid-show');
|
expect(titles).not.toContain('Paid Show');
|
||||||
|
expect(ids).not.toContain('test-show');
|
||||||
// Subscribe from the directory this time: the same feeds, as a grid of cover art.
|
await expect(chart).toHaveCount(top.length);
|
||||||
const tiles = piper.locator('#popular .tile');
|
await expect(chart.first().locator('.no')).toHaveText('1');
|
||||||
const pick = (row, name) => piper.locator(`#dirbar .${row} button`, { hasText: new RegExp(`^${name}$`) });
|
|
||||||
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
|
||||||
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
|
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
|
||||||
|
|
||||||
|
// Then the categories, as tiles, each a page of its own.
|
||||||
|
const cat = name => piper.locator('#directory .cat', { hasText: new RegExp(`^${name}\\d`) });
|
||||||
|
const tiles = piper.locator('#dtiles .tile');
|
||||||
|
expect(byTitle('Test Show')).toMatchObject({ podcast: true, category: 'Technology' });
|
||||||
|
expect(byTitle('Picture Blog')).toMatchObject({ podcast: false });
|
||||||
|
await cat('Technology').click();
|
||||||
|
await expect(piper.locator('#directory h2')).toHaveText('Technology');
|
||||||
|
await expect(tiles).toHaveCount(dir.filter(p => p.category === 'Technology').length);
|
||||||
await expect(tiles.filter({ hasText: 'Test Show' })).toBeVisible();
|
await expect(tiles.filter({ hasText: 'Test Show' })).toBeVisible();
|
||||||
|
// Its way back lands on the tile it went in by.
|
||||||
|
await piper.locator('#directory .dback').click();
|
||||||
|
await expect(cat('Technology')).toBeFocused();
|
||||||
|
|
||||||
|
// What a feed is holds on every page. No blog here names Technology, so Blogs has no such tile.
|
||||||
|
const kind = name => piper.locator('#directory .tabs button', { hasText: new RegExp(`^${name}$`) });
|
||||||
|
await kind('Blogs').click();
|
||||||
|
await expect(kind('Blogs')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(cat('Technology')).toHaveCount(0);
|
||||||
|
await piper.locator('#directory .dall').click();
|
||||||
|
await expect(tiles).toHaveCount(dir.filter(p => !p.podcast).length);
|
||||||
|
await expect(tiles.filter({ hasText: 'Test Show' })).toHaveCount(0);
|
||||||
|
await kind('All').click();
|
||||||
|
await expect(tiles).toHaveCount(dir.length);
|
||||||
await expect(tiles.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible();
|
await expect(tiles.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible();
|
||||||
await expect(tiles.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
|
await expect(tiles.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
|
||||||
await expect(tiles.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
|
await expect(tiles.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
|
||||||
|
// Sorted by name either way, A to Z as it opens, or by subscribers (#122).
|
||||||
|
const byName = dir.map(p => p.title || p.id).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true }));
|
||||||
|
await expect(tiles.first()).toContainText(byName[0]);
|
||||||
|
await piper.locator('#dirsort').selectOption('za');
|
||||||
|
await expect(tiles.first()).toContainText(byName[byName.length - 1]);
|
||||||
|
await expect(tiles).toHaveCount(dir.length);
|
||||||
|
await piper.locator('#dirsort').selectOption('subscribers');
|
||||||
|
await expect(tiles).toHaveCount(dir.length);
|
||||||
|
await piper.locator('#dirsort').selectOption('az');
|
||||||
|
|
||||||
|
// The search box looks through the Directory by name (#126), and clearing it goes back.
|
||||||
|
await piper.locator('#epSearch').fill('test show');
|
||||||
|
await expect(piper.locator('#directory h2')).toHaveText('Results for “test show”');
|
||||||
|
await expect(tiles).toHaveCount(dir.filter(p => (p.title || p.id).toLowerCase().includes('test show')).length);
|
||||||
|
await piper.locator('#epSearch').fill('');
|
||||||
await expect(tiles).toHaveCount(dir.length);
|
await expect(tiles).toHaveCount(dir.length);
|
||||||
|
|
||||||
// Two filters that combine: what a feed is, and what it is about.
|
// A feed you do not subscribe to opens its page here: what it is and its latest items (#128).
|
||||||
expect(dir.find(p => p.id === 'test-show')).toMatchObject({ podcast: true, category: 'Technology' });
|
await tiles.filter({ hasText: 'Test Show' }).locator('.txt').click();
|
||||||
expect(dir.find(p => p.id === 'picture-blog')).toMatchObject({ podcast: false });
|
await expect(piper.locator('.show h2')).toHaveText('Test Show');
|
||||||
await pick('tabs', 'Blogs').click();
|
await expect(piper.locator('#dshow .lep', { hasText: 'First Episode' })).toBeVisible();
|
||||||
await expect(tiles).toHaveCount(dir.filter(p => !p.podcast).length);
|
// What the feed says it is, from its own <description> (#130).
|
||||||
await expect(tiles.filter({ hasText: 'Test Show' })).toHaveCount(0);
|
await expect(piper.locator('#dabout p')).toHaveText('A fixture feed.');
|
||||||
// No empty chips: no blog here names Technology, so Blogs does not offer it.
|
await expect(piper.locator('#dabout button')).toBeHidden();
|
||||||
await expect(pick('chips', 'Technology')).toHaveCount(0);
|
// Back, onto the tile it was opened from, the page under it as it was.
|
||||||
await pick('tabs', 'Podcasts').click();
|
await piper.locator('#directory .dback').click();
|
||||||
await pick('chips', 'Technology').click();
|
await expect(tiles.filter({ hasText: 'Test Show' })).toBeFocused();
|
||||||
await expect(pick('chips', 'Technology')).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
await expect(tiles).toHaveCount(dir.filter(p => p.podcast && p.category === 'Technology').length);
|
|
||||||
// A second press lifts the chip and leaves the kind as it was.
|
|
||||||
await pick('chips', 'Technology').click();
|
|
||||||
await expect(tiles).toHaveCount(dir.filter(p => p.podcast).length);
|
|
||||||
await pick('tabs', 'All').click();
|
|
||||||
await expect(tiles).toHaveCount(dir.length);
|
await expect(tiles).toHaveCount(dir.length);
|
||||||
|
// Only a listed feed has one, and it carries no file or its address.
|
||||||
|
const preview = await (await piper.request.get(`/api/directory/${byTitle('Test Show').id}`)).text();
|
||||||
|
expect(preview).toContain('First Episode');
|
||||||
|
expect(JSON.parse(preview).description).toBe('A fixture feed.');
|
||||||
|
expect(preview).not.toContain('.mp3');
|
||||||
|
expect((await piper.request.get('/api/directory/paid-show')).status()).toBe(404);
|
||||||
|
// Nor does anything else give a feed's items or files to someone who does not subscribe (#129).
|
||||||
|
expect((await piper.request.get('/api/feeds/test-show/entries')).status()).toBe(404);
|
||||||
|
expect((await piper.request.get('/api/feeds/paid-show/entries')).status()).toBe(404);
|
||||||
|
const pics = await page.evaluate(() => api('/api/feeds/picture-blog/entries'));
|
||||||
|
const file = pics.entries.flatMap(e => e.enclosures).find(x => x.path);
|
||||||
|
expect(file).toBeTruthy();
|
||||||
|
expect((await page.request.get(`/media/${file.id}`)).status()).toBe(200);
|
||||||
|
expect((await piper.request.get(`/media/${file.id}`)).status()).toBe(404);
|
||||||
|
expect((await piper.request.post(`/api/enclosures/${file.id}/download`)).status()).toBe(404);
|
||||||
|
expect((await piper.request.delete(`/api/enclosures/${file.id}?force=true`)).status()).toBe(404);
|
||||||
|
expect((await piper.request.post('/api/feeds/picture-blog/download-latest', { data: { count: 1 } })).status()).toBe(404);
|
||||||
|
|
||||||
// Add a feed is for an address; Popular and Directory are where you browse (issue #30).
|
// Add a feed is for an address; the Directory is where you browse (issue #30).
|
||||||
await piper.locator('#addFeed').click();
|
await piper.locator('#addFeed').click();
|
||||||
await expect(piper.locator('#nurl')).toBeVisible();
|
await expect(piper.locator('#nurl')).toBeVisible();
|
||||||
await expect(piper.locator('#modalCard .childrow')).toHaveCount(0);
|
await expect(piper.locator('#modalCard .tile, #modalCard .rank')).toHaveCount(0);
|
||||||
await expect(tiles).toHaveCount(dir.length);
|
// Nor a folder: a feed's folder is its title (#201).
|
||||||
|
await expect(piper.locator('#nfolder')).toHaveCount(0);
|
||||||
await piper.locator('#modalCard button[title="Cancel"]').click();
|
await piper.locator('#modalCard button[title="Cancel"]').click();
|
||||||
|
|
||||||
const row = async () =>
|
const row = async () =>
|
||||||
(await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show');
|
(await (await piper.request.get('/api/directory')).json()).find(p => p.title === 'Test Show');
|
||||||
const before = await row();
|
const before = await row();
|
||||||
expect(before.subscribed).toBe(false);
|
expect(before).toMatchObject({ subscribed: false, feed: null });
|
||||||
await tiles.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click();
|
await tiles.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click();
|
||||||
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
// Everyone counts, you included: it stays listed, marked as yours, with one more subscriber.
|
// Everyone counts, you included: it stays listed, marked as yours by the id it has here, with
|
||||||
expect(await row()).toMatchObject({ subscribed: true, subscribers: before.subscribers + 1 });
|
// one more subscriber once this server has shared again.
|
||||||
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
await expect.poll(row, { timeout: 20_000 }).toMatchObject({ subscribed: true, feed: 'test-show', subscribers: before.subscribers + 1 });
|
||||||
await expect(offered.filter({ hasText: 'Test Show' }).locator('[title^="Subscribed"]')).toBeVisible();
|
expect((await piper.request.get('/api/feeds/test-show/entries')).status()).toBe(200);
|
||||||
await expect(offered.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]')).toHaveCount(0);
|
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
||||||
|
await expect(chart.filter({ hasText: 'Test Show' }).locator('[title^="Subscribed"]')).toBeVisible();
|
||||||
|
await expect(chart.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]')).toHaveCount(0);
|
||||||
|
|
||||||
// All Subscriptions is every item from piper's feeds and only those: the admin's Picture
|
// All Subscriptions is every item from piper's feeds and only those: the admin's Picture
|
||||||
// Blog is not among them.
|
// Blog is not among them.
|
||||||
@@ -922,6 +987,47 @@ test('Popular lists what everyone here reads, but never a private feed', async (
|
|||||||
await ctx.close();
|
await ctx.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the feed list keeps the icons it has drawn when a feed\'s new row comes in (#134)', async ({ page }) => {
|
||||||
|
await expect(page.locator('#feedlist .feed').first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
const kept = await page.evaluate(async () => {
|
||||||
|
const frames = () => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||||
|
// Artwork that loads, on a feed of its own row: the fixtures' fails on purpose, for initials.
|
||||||
|
const row = { ...S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id)), image: '/favicon.png' };
|
||||||
|
patchFeed(row); await frames();
|
||||||
|
const rows = [...document.querySelectorAll('#feedlist .feed')];
|
||||||
|
const before = [...document.querySelectorAll('#feedlist .feed img')];
|
||||||
|
// The same feed's new row again, as a check of every feed sends one for each feed it reads.
|
||||||
|
patchFeed({ ...row }); await frames();
|
||||||
|
const after = [...document.querySelectorAll('#feedlist .feed img')];
|
||||||
|
return { drawn: before.length > 0, redrawn: !rows[0].isConnected, same: after.length === before.length && after.every((img, i) => img === before[i]) };
|
||||||
|
});
|
||||||
|
// The rows are new, the images in them the ones already drawn.
|
||||||
|
expect(kept).toEqual({ drawn: true, redrawn: true, same: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ipx add and rm while the daemon runs reach it, and its next change keeps them (#120)', async ({ page }) => {
|
||||||
|
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` };
|
||||||
|
const ipx = (...args) => execFileSync('./target/debug/ipx', args, { env, encoding: 'utf8' });
|
||||||
|
// By title: the directory names a feed by its address, not by its id here (#205).
|
||||||
|
const listed = async () => (await page.evaluate(() => api('/api/directory'))).map(p => p.title);
|
||||||
|
// A change of the daemon's own: it writes the whole catalogue from the copy it holds, which
|
||||||
|
// wrote a removed feed back and dropped an added one when that copy was old.
|
||||||
|
const daemonWrites = category => page.evaluate(c =>
|
||||||
|
api('/api/feeds/picture-blog', { method: 'PATCH', body: JSON.stringify({ category: c }) }), category);
|
||||||
|
|
||||||
|
const id = ipx('add', '--list', 'http://127.0.0.1:8792/cli.xml').match(/^added (\S+?),? /m)[1];
|
||||||
|
await expect.poll(listed, { timeout: 20_000 }).toContain('Command Line Show');
|
||||||
|
await daemonWrites('Comedy');
|
||||||
|
await expect.poll(listed, { timeout: 20_000 }).toContain('Command Line Show');
|
||||||
|
|
||||||
|
ipx('rm', id);
|
||||||
|
await expect.poll(listed, { timeout: 20_000 }).not.toContain('Command Line Show');
|
||||||
|
await daemonWrites(null);
|
||||||
|
expect(await listed()).not.toContain(id);
|
||||||
|
});
|
||||||
|
|
||||||
test('adding a feed scans it straight away', async ({ page }) => {
|
test('adding a feed scans it straight away', async ({ page }) => {
|
||||||
await page.locator('#addFeed').click();
|
await page.locator('#addFeed').click();
|
||||||
await page.locator('#nurl').fill('http://127.0.0.1:8792/fresh.xml');
|
await page.locator('#nurl').fill('http://127.0.0.1:8792/fresh.xml');
|
||||||
@@ -930,6 +1036,66 @@ test('adding a feed scans it straight away', async ({ page }) => {
|
|||||||
await expect(page.locator('.ep', { hasText: 'Fresh Ep' })).toBeVisible({ timeout: 10_000 });
|
await expect(page.locator('.ep', { hasText: 'Fresh Ep' })).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('adding a page adds the feed it links, and a page with no feed is refused', async ({ page }) => {
|
||||||
|
const feeds = await page.locator('.feed').count();
|
||||||
|
await page.locator('#addFeed').click();
|
||||||
|
await page.locator('#nurl').fill('http://127.0.0.1:8792/nofeed.html');
|
||||||
|
await page.locator('#nsave').click();
|
||||||
|
await expect(page.locator('.toast')).toContainText('links no feed');
|
||||||
|
await expect(page.locator('#modal.on')).toBeVisible(); // left open to correct it
|
||||||
|
await expect(page.locator('.feed')).toHaveCount(feeds);
|
||||||
|
await page.locator('#nurl').fill('http://127.0.0.1:8792/site.html');
|
||||||
|
await page.locator('#nsave').click();
|
||||||
|
await expect(page.locator('.feed', { hasText: 'Linked Site' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reading an item updates its feed\'s count without reloading the list', async ({ page }) => {
|
||||||
|
const unread = page.locator('.feed:not(.group)', { has: page.locator('.badge:not(.zero)') }).first();
|
||||||
|
await expect(unread).toBeVisible({ timeout: 20_000 });
|
||||||
|
const id = await unread.getAttribute('data-id');
|
||||||
|
const badge = page.locator(`.feed[data-id="${id}"] .badge`);
|
||||||
|
const before = Number(await badge.textContent());
|
||||||
|
await unread.click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'Unread' }).click();
|
||||||
|
await expect(page.locator('.ep').first()).toBeVisible();
|
||||||
|
const lists = [];
|
||||||
|
page.on('request', r => { if (new URL(r.url()).pathname === '/api/feeds') lists.push(r.url()); });
|
||||||
|
await page.locator('.ep').first().click(); // opening an item reads it
|
||||||
|
await expect(badge).toHaveText(String(before - 1));
|
||||||
|
expect(lists, 'the row came back with the read, not by reloading the list').toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('artwork comes from iPX, kept, and only as an image', async ({ page }) => {
|
||||||
|
// Every image a feed names is drawn from iPX's address.
|
||||||
|
expect(await page.evaluate(() => artHTML('https://example.com/a.jpg', 'A'))).toContain('src="/api/art?u=https%3A%2F%2Fexample.com%2Fa.jpg"');
|
||||||
|
// Multi Show's items name art.jpg.
|
||||||
|
await expect(page.locator('.feed', { hasText: 'Multi Show' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
const src = '/api/art?u=' + encodeURIComponent('http://127.0.0.1:8792/art.jpg');
|
||||||
|
for (const _ of [1, 2]) { // fetched, then kept
|
||||||
|
const r = await page.request.get(src);
|
||||||
|
expect(r.status()).toBe(200);
|
||||||
|
expect(r.headers()['content-type']).toMatch(/^image\//);
|
||||||
|
expect(r.headers()['content-security-policy']).toContain('sandbox');
|
||||||
|
}
|
||||||
|
// An address no feed names is not fetched.
|
||||||
|
expect((await page.request.get('/api/art?u=' + encodeURIComponent('http://127.0.0.1:8792/show.xml'))).status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an item without a title is named from its text, then its file, then its show and date', async ({ page }) => {
|
||||||
|
const names = await page.evaluate(() => [
|
||||||
|
entryName({ title: 'A Title' }),
|
||||||
|
entryName({ title: ' ', description: '<p>I like the way <b>AI</b> is evolving.</p><img src="x.png">' }),
|
||||||
|
entryName({ description: '<p>' + 'word '.repeat(40) + '</p>' }).text.length,
|
||||||
|
entryName({ enclosures: [{ url: 'https://k.example/media/KARTAS691.mp3?x=1' }] }),
|
||||||
|
entryName({ feed_id: 'nobody', published: 0 }),
|
||||||
|
]);
|
||||||
|
expect(names[0]).toEqual({ text: 'A Title', derived: false });
|
||||||
|
expect(names[1]).toEqual({ text: 'I like the way AI is evolving.', derived: true });
|
||||||
|
expect(names[2]).toBeLessThanOrEqual(121); // cut at a word, with …
|
||||||
|
expect(names[3]).toEqual({ text: 'KARTAS691', derived: true });
|
||||||
|
expect(names[4]).toEqual({ text: 'nobody', derived: true });
|
||||||
|
});
|
||||||
|
|
||||||
test('a deleted file looks as if it was never downloaded', async ({ page }) => {
|
test('a deleted file looks as if it was never downloaded', async ({ page }) => {
|
||||||
// Other people subscribe to Picture Blog by now, so both prompts come; take them.
|
// Other people subscribe to Picture Blog by now, so both prompts come; take them.
|
||||||
page.on('dialog', d => d.accept());
|
page.on('dialog', d => d.accept());
|
||||||
@@ -1114,19 +1280,164 @@ test('keys move through items and places, after Feedly', async ({ page }) => {
|
|||||||
await expect(page.locator('#count')).toContainText('All Subscriptions');
|
await expect(page.locator('#count')).toContainText('All Subscriptions');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an API token acts as its owner until it is revoked', async ({ page, request }) => {
|
||||||
|
// Made in Settings, shown once.
|
||||||
|
await page.locator('#prefs').click();
|
||||||
|
await page.locator('#tokname').fill('test agent');
|
||||||
|
await page.locator('#tokadd').click();
|
||||||
|
const token = await page.locator('#tokval').inputValue();
|
||||||
|
expect(token).toMatch(/^ipx_[0-9a-f]{64}$/);
|
||||||
|
await expect(page.locator('.tokrow', { hasText: 'test agent' })).toContainText('last used never');
|
||||||
|
|
||||||
|
// Sent as a Bearer header, with no cookie, it is the person who made it.
|
||||||
|
const as = t => ({ headers: { Authorization: `Bearer ${t}` } });
|
||||||
|
const me = await (await request.get('/api/me', as(token))).json();
|
||||||
|
expect(me.name).toBe(await page.evaluate(() => S.me.name));
|
||||||
|
expect((await request.get('/api/feeds', as(token))).status()).toBe(200);
|
||||||
|
// A wrong one is turned away, not let through as anyone.
|
||||||
|
expect((await request.get('/api/me', as('ipx_' + '0'.repeat(64)))).status()).toBe(401);
|
||||||
|
// Only the hash is kept: the list never carries the token.
|
||||||
|
const listed = await page.evaluate(() => api('/api/tokens'));
|
||||||
|
expect(JSON.stringify(listed)).not.toContain(token);
|
||||||
|
expect(listed.find(t => t.name === 'test agent').last_used).toBeTruthy();
|
||||||
|
|
||||||
|
// Revoked, it opens nothing.
|
||||||
|
await page.locator('.tokrow', { hasText: 'test agent' }).locator('[data-tok]').click();
|
||||||
|
await expect(page.locator('.tokrow', { hasText: 'test agent' })).toHaveCount(0);
|
||||||
|
expect((await request.get('/api/me', as(token))).status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
test('an admin can give a blog its Directory category', async ({ page }) => {
|
test('an admin can give a blog its Directory category', async ({ page }) => {
|
||||||
const patch = (id, category) => page.evaluate(([id, category]) =>
|
const patch = (id, category) => page.evaluate(([id, category]) =>
|
||||||
api(`/api/feeds/${id}`, { method: 'PATCH', body: JSON.stringify({ category }) }), [id, category]);
|
api(`/api/feeds/${id}`, { method: 'PATCH', body: JSON.stringify({ category }) }), [id, category]);
|
||||||
const listed = async id => (await page.evaluate(() => api('/api/directory'))).find(p => p.id === id);
|
// By title: the directory names a feed by its address, not by its id here (#205). It hears of
|
||||||
|
// a category given here with this server's next snapshot, sent as soon as one is saved.
|
||||||
|
const listed = async title => (await page.evaluate(() => api('/api/directory'))).find(p => p.title === title);
|
||||||
|
|
||||||
await patch('picture-blog', 'Visual Arts');
|
await patch('picture-blog', 'Visual Arts');
|
||||||
expect(await listed('picture-blog')).toMatchObject({ podcast: false, category: 'Visual Arts' });
|
// Apple's subcategory, under its category, as the Directory browses them (#118).
|
||||||
|
await expect.poll(() => listed('Picture Blog'), { timeout: 20_000 })
|
||||||
|
.toMatchObject({ podcast: false, category: 'Arts', subcategory: 'Visual Arts' });
|
||||||
|
// A category's subcategories are on its own page, as Apple's directory has them.
|
||||||
|
const pill = name => page.locator('#directory .pills button', { hasText: new RegExp(`^${name}\\d`) });
|
||||||
|
await page.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
||||||
|
await expect(page.locator('#directory .pills')).toHaveCount(0);
|
||||||
|
await page.locator('#directory .cat', { hasText: /^Arts\d/ }).click();
|
||||||
|
await pill('Visual Arts').click();
|
||||||
|
await expect(pill('Visual Arts')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(page.locator('#dtiles .tile')).toHaveCount(1);
|
||||||
// A feed's own iTunes category wins over one given here.
|
// A feed's own iTunes category wins over one given here.
|
||||||
await patch('test-show', 'Comedy');
|
await patch('test-show', 'Comedy');
|
||||||
expect((await listed('test-show')).category).toBe('Technology');
|
expect((await listed('Test Show')).category).toBe('Technology');
|
||||||
|
|
||||||
for (const id of ['picture-blog', 'test-show']) await patch(id, null);
|
for (const id of ['picture-blog', 'test-show']) await patch(id, null);
|
||||||
expect((await listed('picture-blog')).category).toBeNull();
|
await expect.poll(async () => (await listed('Picture Blog')).category, { timeout: 20_000 }).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the admin page sets up the directory, and a feed marked private leaves it (#205)', async ({ page }) => {
|
||||||
|
const listed = async () => (await page.evaluate(() => api('/api/directory'))).map(p => p.title);
|
||||||
|
await expect.poll(listed, { timeout: 20_000 }).toContain('Multi Show');
|
||||||
|
|
||||||
|
await page.goto('/admin#directory');
|
||||||
|
await expect(page.locator('#durl')).toHaveValue('http://127.0.0.1:8795');
|
||||||
|
// The token is set and never shown: the field is empty, and says so.
|
||||||
|
await expect(page.locator('#dtoken')).toHaveValue('');
|
||||||
|
await expect(page.locator('#dtoken')).toHaveAttribute('placeholder', /^Set\./);
|
||||||
|
const settings = await page.evaluate(() => api('/api/settings'));
|
||||||
|
expect(settings.directory_token_set).toBe(true);
|
||||||
|
expect(JSON.stringify(settings)).not.toContain('ipxd_');
|
||||||
|
await expect(page.locator('#dstatus')).toContainText('Connected as test, which may browse, share and administer the directory.');
|
||||||
|
await expect(page.locator('#dstatus')).toContainText(/Shared \d+ feeds? at/);
|
||||||
|
|
||||||
|
// A wrong token says so, in words.
|
||||||
|
await page.locator('#dtoken').fill('not-the-token');
|
||||||
|
await page.locator('#dsave').click();
|
||||||
|
await expect(page.locator('#dstatus')).toContainText('does not accept this server');
|
||||||
|
await page.locator('#dtoken').fill(require('./global-setup').DIRECTORY_TOKEN);
|
||||||
|
await page.locator('#dsave').click();
|
||||||
|
await expect(page.locator('#dstatus')).toContainText('Connected as test');
|
||||||
|
|
||||||
|
// Marked private in its settings, a feed is out of the directory with the next snapshot.
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('.feed', { hasText: 'Multi Show' }).click();
|
||||||
|
await page.locator('#content .acts [data-a="settings"]').click();
|
||||||
|
const priv = page.locator('#sprivate');
|
||||||
|
await expect(priv).not.toBeChecked();
|
||||||
|
await priv.check({ force: true });
|
||||||
|
await page.locator('#ssave').click();
|
||||||
|
await expect.poll(listed, { timeout: 20_000 }).not.toContain('Multi Show');
|
||||||
|
// Its address shows nothing, so only the mark keeps it out; and an admin can take it off.
|
||||||
|
const row = await page.evaluate(() => api('/api/feeds')).then(fs => fs.find(f => f.id === 'multi-show'));
|
||||||
|
expect(row).toMatchObject({ private: true, private_by_address: false });
|
||||||
|
await page.evaluate(() => api('/api/feeds/multi-show', { method: 'PATCH', body: JSON.stringify({ private: false }) }));
|
||||||
|
await expect.poll(listed, { timeout: 20_000 }).toContain('Multi Show');
|
||||||
|
// A feed whose address carries a key is private whatever it is marked.
|
||||||
|
const paid = await page.evaluate(() => api('/api/feeds')).then(fs => fs.find(f => f.id === 'paid-show'));
|
||||||
|
if (paid) expect(paid.private_by_address).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an admin removes a feed from the directory, and unblocks it (#206)', async ({ page, browser }) => {
|
||||||
|
const all = () => page.evaluate(() => api('/api/directory'));
|
||||||
|
const listed = async () => (await all()).map(p => p.title);
|
||||||
|
await expect.poll(listed, { timeout: 20_000 }).toContain('Aardvark Radio');
|
||||||
|
const id = (await all()).find(p => p.title === 'Aardvark Radio').id;
|
||||||
|
|
||||||
|
// Someone who is not an admin is neither offered it nor allowed it.
|
||||||
|
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', 'piper'], { input: 'piperpassword', env });
|
||||||
|
} catch (e) {
|
||||||
|
if (!String(e.stderr || e.stdout).includes('already exists')) throw e;
|
||||||
|
}
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const piper = await ctx.newPage();
|
||||||
|
await piper.goto('/login');
|
||||||
|
await piper.locator('#name').fill('piper');
|
||||||
|
await piper.locator('#pw').fill('piperpassword');
|
||||||
|
await piper.locator('button[type=submit]').click();
|
||||||
|
await expect(piper.locator('#feedlist')).toBeVisible();
|
||||||
|
expect((await (await piper.request.get(`/api/directory/${id}`)).json()).can_admin).toBe(false);
|
||||||
|
expect((await piper.request.delete(`/api/directory/${id}`)).status()).toBe(403);
|
||||||
|
expect((await piper.request.post(`/api/directory/${id}/refresh`)).status()).toBe(403);
|
||||||
|
expect((await piper.request.get('/api/directory-admin')).status()).toBe(403);
|
||||||
|
expect((await piper.request.delete('/api/directory-instances/test')).status()).toBe(403);
|
||||||
|
|
||||||
|
// Made an admin, and subscribed to none of it, piper opens its page and is offered both. The
|
||||||
|
// admin here subscribes to every fixture, so their tiles open their own feeds instead.
|
||||||
|
const users = await page.evaluate(() => api('/api/users'));
|
||||||
|
const pid = users.find(u => u.name === 'piper').id;
|
||||||
|
const setAdmin = admin => page.evaluate(([id, admin]) => api(`/api/users/${id}`, { method: 'PATCH', body: JSON.stringify({ admin }) }), [pid, admin]);
|
||||||
|
await setAdmin(true);
|
||||||
|
try {
|
||||||
|
await piper.goto('/');
|
||||||
|
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
||||||
|
await piper.locator('#epSearch').fill('aardvark');
|
||||||
|
await piper.locator('#dtiles .tile', { hasText: 'Aardvark Radio' }).locator('.txt').click();
|
||||||
|
await expect(piper.locator('.show h2')).toHaveText('Aardvark Radio');
|
||||||
|
await piper.locator('.show [data-a="drefresh"]').click();
|
||||||
|
await expect(piper.locator('.toast', { hasText: 'Checked' })).toBeVisible();
|
||||||
|
await expect(piper.locator('.show [data-a="dremove"]')).toHaveCount(1);
|
||||||
|
piper.once('dialog', d => d.accept());
|
||||||
|
await piper.locator('.show [data-a="dremove"]').click();
|
||||||
|
await expect(piper.locator('.show h2')).toHaveCount(0);
|
||||||
|
expect(await listed()).not.toContain('Aardvark Radio');
|
||||||
|
} finally {
|
||||||
|
await setAdmin(false);
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin, Directory lists the servers sharing and what is blocked, by title, never its address.
|
||||||
|
await page.goto('/admin#directory');
|
||||||
|
await expect(page.locator('#dadmin .urow[data-name="test"]')).toContainText(/\d+ feeds? · \d+ subscribers?/);
|
||||||
|
const blocked = page.locator('#dadmin .urow[data-id]', { hasText: 'Aardvark Radio' });
|
||||||
|
await expect(blocked).toBeVisible();
|
||||||
|
expect(JSON.stringify(await page.evaluate(() => api('/api/directory-admin')))).not.toMatch(/https?:/);
|
||||||
|
// Unblocked, it is back with the snapshot ipx sends straight away, unchanged as it is.
|
||||||
|
await blocked.locator('[data-a="unblock"]').click();
|
||||||
|
await expect(blocked).toHaveCount(0);
|
||||||
|
await expect.poll(listed, { timeout: 20_000 }).toContain('Aardvark Radio');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the pinned heading sits over its pins, and the page is set in Inter', async ({ page }) => {
|
test('the pinned heading sits over its pins, and the page is set in Inter', async ({ page }) => {
|
||||||
@@ -1178,47 +1489,81 @@ test('a file not yet downloaded has its icon in line with the rest of its row',
|
|||||||
for (const o of offsets) expect(Math.abs(o)).toBeLessThanOrEqual(1);
|
for (const o of offsets) expect(Math.abs(o)).toBeLessThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the logo is the dark one in dark mode and the light one in light mode', async ({ page }) => {
|
||||||
|
const shown = () => page.locator('#applogo img:visible').getAttribute('src');
|
||||||
|
await page.evaluate(() => setTheme('modern', 'dark'));
|
||||||
|
expect(await shown()).toMatch(/^\/logo-dark\.svg\?v=/);
|
||||||
|
await page.evaluate(() => setTheme('modern', 'light'));
|
||||||
|
expect(await shown()).toMatch(/^\/logo\.svg\?v=/);
|
||||||
|
// Paper has only a light palette, so the light logo whatever the mode asked.
|
||||||
|
await page.evaluate(() => setTheme('paper', 'dark'));
|
||||||
|
expect(await shown()).toMatch(/^\/logo\.svg\?v=/);
|
||||||
|
await page.evaluate(() => setTheme('modern', 'dark'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/api/status gives a dashboard the counts, with the shared token as a cookie', async ({ page }) => {
|
||||||
|
// As Homepage's customapi widget asks: no session, only the token in a Cookie header.
|
||||||
|
const r = await page.request.get('/api/status', { headers: { cookie: `ipx_token=${TOKEN}` } });
|
||||||
|
expect(r.status()).toBe(200);
|
||||||
|
const s = await r.json();
|
||||||
|
for (const k of ['feeds', 'pending', 'downloaded']) expect(typeof s[k], k).toBe('number');
|
||||||
|
expect(s.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||||
|
// The logo names ipx's version and the page's, each its own (#207).
|
||||||
|
await expect(page.locator('#applogo')).toHaveAttribute('title', new RegExp(`^iPX ${s.version}, page \\d+\\.\\d+\\.\\d+`));
|
||||||
|
const none = await page.request.get('/api/status', { headers: { cookie: '' } });
|
||||||
|
expect(none.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
test('the favicon is the logo, square, from both pages', async ({ page }) => {
|
test('the favicon is the logo, square, from both pages', async ({ page }) => {
|
||||||
await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', /^\/favicon\.png\?v=[0-9a-f]{12}$/);
|
await page.evaluate(() => setTheme('modern', 'light'));
|
||||||
|
await expect(page.locator('#favicon')).toHaveAttribute('href', /^\/favicon\.png\?v=[0-9a-f]{12}$/);
|
||||||
|
await page.evaluate(() => setTheme('modern', 'dark'));
|
||||||
|
await expect(page.locator('#favicon')).toHaveAttribute('href', /^\/favicon-dark\.png\?v=[0-9a-f]{12}$/);
|
||||||
// A browser asks for /favicon.ico on its own, signed in or not.
|
// A browser asks for /favicon.ico on its own, signed in or not.
|
||||||
for (const path of ['/favicon.ico', '/favicon.png', '/apple-touch-icon.png']) {
|
for (const path of ['/favicon.ico', '/favicon.png', '/favicon-dark.png', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png']) {
|
||||||
const r = await page.request.get(path, { headers: { cookie: '' } });
|
const r = await page.request.get(path, { headers: { cookie: '' } });
|
||||||
expect(r.status(), path).toBe(200);
|
expect(r.status(), path).toBe(200);
|
||||||
expect(r.headers()['content-type'], path).toBe('image/png');
|
expect(r.headers()['content-type'], path).toBe('image/png');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a feed error is marked in the same column as the folder triangles', async ({ page }) => {
|
test('a failing feed is marked on its artwork and says why', async ({ page }) => {
|
||||||
await expect(page.locator('.feed.group .chev').first()).toBeVisible();
|
await expect(page.locator('.feed.group .chev').first()).toBeVisible();
|
||||||
if ((await page.locator('.feed.group .chev').first().getAttribute('aria-expanded')) !== 'true')
|
if ((await page.locator('.feed.group .chev').first().getAttribute('aria-expanded')) !== 'true')
|
||||||
await page.locator('.feed.group .chev').first().click();
|
await page.locator('.feed.group .chev').first().click();
|
||||||
// Faked in the page: no fixture feed fails. A feed on its own, and one inside a folder.
|
// Faked in the page: no fixture feed fails. A feed on its own failing for a day, and one
|
||||||
await page.evaluate(() => {
|
// inside a folder that failed its last check.
|
||||||
|
const solo = await page.evaluate(() => {
|
||||||
S.feeds.find(f => f.group).last_error = 'HTTP 404';
|
S.feeds.find(f => f.group).last_error = 'HTTP 404';
|
||||||
S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id)).last_error = 'timed out';
|
const f = S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id));
|
||||||
|
f.last_error = 'HTTP 404 Not Found';
|
||||||
|
f.failing = { reason: 'The publisher took this feed down, or moved it.' };
|
||||||
renderFeeds();
|
renderFeeds();
|
||||||
|
return f.id;
|
||||||
});
|
});
|
||||||
await expect(page.locator('.ferr')).toHaveCount(2);
|
await expect(page.locator('.fart .ferr svg')).toHaveCount(3); // both feeds and the folder
|
||||||
await expect(page.locator('.ferr svg')).toHaveCount(2); // the icon, not a "!"
|
|
||||||
await expect(page.locator('.chev.bad')).toHaveCount(1); // the folder holding one
|
await expect(page.locator('.chev.bad')).toHaveCount(1); // the folder holding one
|
||||||
const xs = await page.$$eval('.chev, .ferr', els =>
|
const row = page.locator(`.feed[data-id="${solo}"]`);
|
||||||
els.map(e => { const r = e.getBoundingClientRect(); return Math.round(r.left + r.width / 2); }));
|
await expect(row).toHaveClass(/failing/);
|
||||||
expect(new Set(xs).size, JSON.stringify(xs)).toBe(1);
|
await expect(row.locator('small')).toHaveText('The publisher took this feed down, or moved it.');
|
||||||
|
await expect(page.locator('.feed.group.err small').first()).toHaveText('1 feed not updating');
|
||||||
await page.reload(); // put the real list back
|
await page.reload(); // put the real list back
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('touch gestures on a phone', () => {
|
test.describe('touch gestures on a phone', () => {
|
||||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||||
|
|
||||||
// Playwright's touchscreen only taps; a drag goes through the DevTools protocol.
|
// Playwright's touchscreen only taps; a drag goes through the DevTools protocol. Sent all at
|
||||||
|
// once, in order: awaited one by one, a busy machine stretched a drag past the 800 ms a swipe
|
||||||
|
// has, and it was taken for a slow drag and went nowhere (#184).
|
||||||
async function drag(page, from, to, lift = true) {
|
async function drag(page, from, to, lift = true) {
|
||||||
const cdp = await page.context().newCDPSession(page);
|
const cdp = await page.context().newCDPSession(page);
|
||||||
const steps = 8;
|
const steps = 8, touch = (type, touchPoints) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints });
|
||||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [from] });
|
const sent = [touch('touchStart', [from])];
|
||||||
for (let i = 1; i <= steps; i++)
|
for (let i = 1; i <= steps; i++)
|
||||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{
|
sent.push(touch('touchMove', [{ x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }]));
|
||||||
x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }] });
|
if (lift) sent.push(touch('touchEnd', []));
|
||||||
if (lift) await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
await Promise.all(sent);
|
||||||
return cdp;
|
return cdp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1255,6 +1600,34 @@ test.describe('touch gestures on a phone', () => {
|
|||||||
await expect(page.locator('body')).not.toHaveClass(/reading/);
|
await expect(page.locator('body')).not.toHaveClass(/reading/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('on the Unread tab, a swipe back goes to the item just read', async ({ page }) => {
|
||||||
|
await page.locator('#burger').click();
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
|
||||||
|
// Earlier tests read these; both have to be unread to be on the Unread tab.
|
||||||
|
await page.evaluate(() => Promise.all(S.entries.map(e => setRead(e, false))));
|
||||||
|
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
||||||
|
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
|
||||||
|
const titles = await page.locator('.ep .t').allTextContents();
|
||||||
|
await page.locator('.ep').first().click();
|
||||||
|
const shown = page.locator('#detail .dt');
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
|
||||||
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
|
||||||
|
await expect(shown).toHaveText(titles[1]);
|
||||||
|
// The first is read now, and used to be gone from the list already, so this went back to
|
||||||
|
// the list instead.
|
||||||
|
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 });
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 });
|
||||||
|
await expect(shown).toHaveText(titles[1]);
|
||||||
|
// Out of the reader, the ones read on the way leave the Unread tab as before.
|
||||||
|
await page.locator('#dback').click();
|
||||||
|
await expect(page.locator('body')).not.toHaveClass(/reading/);
|
||||||
|
await expect(page.locator('.ep .t', { hasText: titles[0] })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('pulling the list down from its top checks the feed for new items', async ({ page }) => {
|
test('pulling the list down from its top checks the feed for new items', async ({ page }) => {
|
||||||
await page.locator('#burger').click();
|
await page.locator('#burger').click();
|
||||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
@@ -1264,6 +1637,15 @@ test.describe('touch gestures on a phone', () => {
|
|||||||
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
|
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
|
||||||
expect((await fetch).postDataJSON()).toEqual({ feed: 'test-show', force: true });
|
expect((await fetch).postDataJSON()).toEqual({ feed: 'test-show', force: true });
|
||||||
await expect(page.locator('#pulltip')).toHaveCount(0); // the note goes on letting go
|
await expect(page.locator('#pulltip')).toHaveCount(0); // the note goes on letting go
|
||||||
|
// and a spinner says the check started, for a couple of seconds.
|
||||||
|
await expect(page.locator('#pullspin')).toBeVisible();
|
||||||
|
// A second pull while it is up checks nothing more.
|
||||||
|
let again = 0;
|
||||||
|
page.on('request', r => { if (r.url().endsWith('/api/fetch')) again++; });
|
||||||
|
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
|
||||||
|
await expect(page.locator('#pullspin')).toHaveCount(1);
|
||||||
|
await expect(page.locator('#pullspin')).toHaveCount(0, { timeout: 5_000 });
|
||||||
|
expect(again).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1383,3 +1765,200 @@ test('Share hands the feed, the item and the file to the share sheet', async ({
|
|||||||
{ title: 'Second Episode', url: expect.stringContaining('/ep1.mp3?2') },
|
{ title: 'Second Episode', url: expect.stringContaining('/ep1.mp3?2') },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a refresh button turns while what it checks is being checked', 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));
|
||||||
|
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(btn)).toBe('none');
|
||||||
|
expect(await spin('#scanAll .i')).toBe('ipxspin');
|
||||||
|
await page.evaluate(() => setScanning('multi-show', false));
|
||||||
|
expect(await spin('#scanAll .i')).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('on a narrow phone', () => {
|
||||||
|
test.use({ viewport: { width: 360, height: 760 } });
|
||||||
|
|
||||||
|
test("a feed's buttons stay on one row, and the bar round the island is the page's colour", async ({ page }) => {
|
||||||
|
// Seven 44px buttons did not fit 360px, and Unsubscribe went onto a row of its own (#146).
|
||||||
|
await page.locator('#burger').click();
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).first().click();
|
||||||
|
const tops = await page.locator('#content .acts .btn').evaluateAll(bs => bs.map(b => b.getBoundingClientRect().top));
|
||||||
|
expect(tops.length).toBe(7);
|
||||||
|
expect(new Set(tops).size).toBe(1);
|
||||||
|
// iOS 26 paints behind the status bar and the Dynamic Island in the colour of a sticky bar at
|
||||||
|
// the top edge, older ones in theme-color: both the top bar's, which is the page's (#147).
|
||||||
|
const bar = await page.locator('#topbar').evaluate(b => [getComputedStyle(b).position, b.getBoundingClientRect().top, getComputedStyle(b).backgroundColor]);
|
||||||
|
expect(bar.slice(0, 2)).toEqual(['sticky', 0]);
|
||||||
|
expect(bar[2]).toBe(await page.evaluate(() => getComputedStyle(document.body).backgroundColor));
|
||||||
|
expect(await page.evaluate(() => document.querySelector('meta[name=theme-color]').content)).toBe(bar[2]);
|
||||||
|
// Glass's bar is see-through on a desktop, which no flat strip can match: solid on a phone.
|
||||||
|
for (const mode of ['dark', 'light']) {
|
||||||
|
const [c, tc] = await page.evaluate(m => { setTheme('glass', m);
|
||||||
|
return [getComputedStyle(document.querySelector('#topbar')).backgroundColor, document.querySelector('meta[name=theme-color]').content]; }, mode);
|
||||||
|
expect(c).toMatch(/^rgb\(/);
|
||||||
|
expect(tc).toBe(c);
|
||||||
|
}
|
||||||
|
await page.evaluate(() => setTheme('modern', 'dark'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a page brought back into view catches up with what was read elsewhere', async ({ page }) => {
|
||||||
|
// Reading an item tells no other page, and a phone's page misses everything while it is in the
|
||||||
|
// background: All Subscriptions went on counting an item read elsewhere as unread (#168).
|
||||||
|
const badge = page.locator('.place[data-id=":all"] .badge');
|
||||||
|
const [one] = (await (await page.request.get('/api/entries?limit=1')).json()).entries;
|
||||||
|
const flag = read => page.request.post(`/api/entries/${encodeURIComponent(one.feed_id)}/${encodeURIComponent(one.guid)}/flags`, { data: { read } });
|
||||||
|
await flag(false);
|
||||||
|
await page.reload();
|
||||||
|
const before = Number(await badge.textContent());
|
||||||
|
expect(before).toBeGreaterThan(0);
|
||||||
|
await flag(true); // read on another device
|
||||||
|
await page.evaluate(() => {
|
||||||
|
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
});
|
||||||
|
await expect(badge).toHaveText(String(before - 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a feed\'s settings say what downloads, take words as chips, and keep what is saved', async ({ page }) => {
|
||||||
|
// The redesigned dialog (#167): the download choice, word chips, the fold, and a save that holds.
|
||||||
|
const open = async () => {
|
||||||
|
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
||||||
|
await page.locator('#content .acts [data-a="settings"]').click();
|
||||||
|
await expect(page.locator('#modalCard.fset')).toBeVisible();
|
||||||
|
};
|
||||||
|
await open();
|
||||||
|
// The strip is the feed's own items, and Newest 5 lights every one of its two.
|
||||||
|
await expect(page.locator('#sepwin i')).toHaveCount(2);
|
||||||
|
await page.locator('.seg label', { hasText: 'Newest' }).click();
|
||||||
|
await page.locator('#smax').fill('5');
|
||||||
|
await expect(page.locator('#sepwin i.on')).toHaveCount(2);
|
||||||
|
await expect(page.locator('#swcap')).toContainText('Downloads the newest');
|
||||||
|
// Words become chips with Enter or a comma, and come off with their x.
|
||||||
|
await page.locator('#skwin').fill('first, second');
|
||||||
|
await page.locator('#skwin').press('Enter');
|
||||||
|
await expect(page.locator('#skw .chip')).toHaveCount(2);
|
||||||
|
await page.locator('#skw .chip', { hasText: 'second' }).locator('.chipx').click();
|
||||||
|
await expect(page.locator('#skw .chip')).toHaveCount(1);
|
||||||
|
await page.locator('#sblockin').fill('spoiler');
|
||||||
|
await page.locator('#sblockin').press('Enter');
|
||||||
|
// What only automatic downloads use folds away with them.
|
||||||
|
await page.locator('.switch', { hasText: 'Download automatically' }).click();
|
||||||
|
await expect(page.locator('#sdeps')).toHaveClass(/\boff\b/);
|
||||||
|
await page.locator('.switch', { hasText: 'Download automatically' }).click();
|
||||||
|
await expect(page.locator('#sdeps')).not.toHaveClass(/\boff\b/);
|
||||||
|
await page.locator('#ssave').click();
|
||||||
|
await expect(page.locator('#modal.on')).toBeHidden();
|
||||||
|
|
||||||
|
// Saved: open again and it is all there.
|
||||||
|
await open();
|
||||||
|
await expect(page.locator('[name=swin][value=newest]')).toBeChecked();
|
||||||
|
await expect(page.locator('#smax')).toHaveValue('5');
|
||||||
|
await expect(page.locator('#skw .chip')).toHaveText(['first']);
|
||||||
|
await expect(page.locator('#sblock .chip')).toHaveText(['spoiler']);
|
||||||
|
|
||||||
|
// Back as it was, for the tests after this one.
|
||||||
|
await page.locator('.seg label', { hasText: 'Default' }).click();
|
||||||
|
await page.locator('#skw .chipx').click();
|
||||||
|
await page.locator('#sblock .chipx').click();
|
||||||
|
await page.locator('#ssave').click();
|
||||||
|
await expect(page.locator('#modal.on')).toBeHidden();
|
||||||
|
await open();
|
||||||
|
await expect(page.locator('[name=swin][value=default]')).toBeChecked();
|
||||||
|
await expect(page.locator('#skw .chip, #sblock .chip')).toHaveCount(0);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DeadAir is off until turned on for a show, then plays it shortened, with your pause, and switches back', async ({ page }) => {
|
||||||
|
// #171/#172. A show of its own, with real audio, added here and left at the end.
|
||||||
|
const { id } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/quiet.xml' } })).json();
|
||||||
|
const entry = async () => {
|
||||||
|
const r = await page.request.get(`/api/feeds/${encodeURIComponent(id)}/entries`);
|
||||||
|
return (await r.json()).entries?.[0];
|
||||||
|
};
|
||||||
|
await expect.poll(async () => (await entry())?.guid, { timeout: 15_000 }).toBe('quiet-1');
|
||||||
|
await page.request.post(`/api/feeds/${encodeURIComponent(id)}/download-latest`, { data: { count: 1 } });
|
||||||
|
await expect.poll(async () => !!(await entry())?.enclosures[0]?.path, { timeout: 15_000 }).toBe(true);
|
||||||
|
// Off for a show until someone turns it on: nothing is cut.
|
||||||
|
expect((await entry()).enclosures[0].deadair).toBeUndefined();
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// On, in the show's settings.
|
||||||
|
await page.locator('#feedlist .feed', { hasText: 'Quiet Show' }).first().click();
|
||||||
|
await page.locator('#content .acts [data-a="settings"]').click();
|
||||||
|
const sw = page.locator('#sdead');
|
||||||
|
await expect(sw).not.toBeChecked();
|
||||||
|
await sw.locator('xpath=..').click();
|
||||||
|
await page.locator('#ssave').click();
|
||||||
|
// Found at once, not at the next pass, and the row says so when it is done without a reload:
|
||||||
|
// "Cutting dead air…" until then, then about 1.7 s off each of four 2 s silences.
|
||||||
|
const row = page.locator('.ep', { hasText: 'Pauses Between' });
|
||||||
|
await expect(row).toContainText('s shorter', { timeout: 20_000 });
|
||||||
|
expect((await entry()).enclosures[0].deadair).toBeGreaterThan(5);
|
||||||
|
await expect(row).not.toContainText('Cutting dead air');
|
||||||
|
|
||||||
|
// A longer pause of your own (#182) saves less.
|
||||||
|
const usual = (await entry()).enclosures[0].deadair;
|
||||||
|
await page.locator('#content .acts [data-a="settings"]').click();
|
||||||
|
await expect(page.locator('#sdpause')).toHaveValue('300');
|
||||||
|
await page.locator('#sdpause').fill('500');
|
||||||
|
await page.locator('#ssave').click();
|
||||||
|
await expect.poll(async () => (await entry()).enclosures[0].deadair).toBeLessThan(usual);
|
||||||
|
|
||||||
|
// 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' } })]);
|
||||||
|
return { status: d.status, name: d.headers.get('content-disposition'), size: (await d.arrayBuffer()).byteLength, full: (await o.arrayBuffer()).byteLength,
|
||||||
|
// Kept out of shared caches, or Cloudflare serves its own copy to anyone (#189).
|
||||||
|
caching: [d, o, p].map(r => r.headers.get('cache-control')), piece: p.status };
|
||||||
|
}, (await entry()).enclosures[0].id);
|
||||||
|
expect(saved.status).toBe(200);
|
||||||
|
expect(saved.name).toContain('DeadAir');
|
||||||
|
expect(saved.size).toBeLessThan(saved.full);
|
||||||
|
expect(saved.caching).toEqual(['private', 'private', 'private']);
|
||||||
|
expect(saved.piece).toBe(206);
|
||||||
|
|
||||||
|
// Played through DeadAir's playlist (#181), by hls.js in Chromium, with the switch beside the
|
||||||
|
// speed pressed. The element's own src is then hls.js's blob: address.
|
||||||
|
// How long it took to start goes to the server's log, under the trace of the tap (#190).
|
||||||
|
const started = page.waitForRequest(r => r.url().endsWith('/api/client') && r.postDataJSON()?.ev === 'play_start');
|
||||||
|
const piece = page.waitForRequest(r => r.url().endsWith('/deadair.mp3'));
|
||||||
|
await row.locator('[data-a=play]').click();
|
||||||
|
await expect.poll(() => page.evaluate(() => player.src)).toMatch(/\/deadair\.m3u8$/);
|
||||||
|
const report = await started;
|
||||||
|
expect(report.postDataJSON()).toMatchObject({ deadair: true });
|
||||||
|
expect(report.postDataJSON().ms).toBeGreaterThan(0);
|
||||||
|
expect(report.headers().traceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
|
||||||
|
// hls.js's own requests carry the same trace as the tap that played them (#192).
|
||||||
|
const traceOf = h => h.traceparent?.split('-')[1];
|
||||||
|
expect(traceOf((await piece).headers())).toBe(traceOf(report.headers()));
|
||||||
|
await expect.poll(() => page.evaluate(() => document.querySelector('#audio').currentTime), { timeout: 15_000 }).toBeGreaterThan(0.5);
|
||||||
|
await expect(page.locator('#pdead')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
// Off from the player: the original, from the same place.
|
||||||
|
await page.locator('#pdead').click();
|
||||||
|
await expect(page.locator('#pdead')).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await expect.poll(() => page.evaluate(() => document.querySelector('#audio').getAttribute('src'))).toMatch(/\/media\/\d+$/);
|
||||||
|
expect((await entry()).enclosures[0].deadair).toBeUndefined();
|
||||||
|
|
||||||
|
await page.locator('#pclose').click();
|
||||||
|
await page.request.delete(`/api/feeds/${encodeURIComponent(id)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a downloaded file\'s buttons sit on one row in the files pane', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1280, height: 800 });
|
||||||
|
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
||||||
|
await expect(page.locator('.ep').first()).toBeVisible();
|
||||||
|
await page.evaluate(() => showDetail(S.entries.find(e => e.enclosures.some(x => x.path))));
|
||||||
|
const box = page.locator('#files .encbox').first();
|
||||||
|
await expect(box.locator('.btn')).toHaveCount(4);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|||||||
5
tests/ui/fixtures/cli.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<rss version="2.0"><channel><title>Command Line Show</title><link>http://127.0.0.1:8792/</link>
|
||||||
|
<description>Added with ipx add while the daemon runs.</description>
|
||||||
|
<item><title>From the Command Line</title><guid>cli-1</guid><description>x</description></item>
|
||||||
|
</channel></rss>
|
||||||
4
tests/ui/fixtures/linked.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<rss version="2.0"><channel><title>Linked Site</title><link>http://127.0.0.1:8792/site.html</link>
|
||||||
|
<item><title>Linked Post</title><guid>linked-1</guid></item>
|
||||||
|
</channel></rss>
|
||||||
2
tests/ui/fixtures/nofeed.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><title>No Feed Here</title></head><body>A site that links no feed.</body></html>
|
||||||
BIN
tests/ui/fixtures/quiet.mp3
Normal file
8
tests/ui/fixtures/quiet.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||||
|
<channel><title>Quiet Show</title><link>http://127.0.0.1:8792/</link><description>Real audio with long silences, for DeadAir.</description>
|
||||||
|
<item><title>Pauses Between</title><guid>quiet-1</guid>
|
||||||
|
<pubDate>Mon, 15 Sep 2026 10:00:00 +0000</pubDate>
|
||||||
|
<description>A second of tone, then two of silence, five times.</description>
|
||||||
|
<enclosure url="http://127.0.0.1:8792/quiet.mp3" length="60518" type="audio/mpeg"/></item>
|
||||||
|
</channel></rss>
|
||||||
@@ -12,7 +12,10 @@ http.createServer((req, res) => {
|
|||||||
fs.readFile(file, (err, body) => {
|
fs.readFile(file, (err, body) => {
|
||||||
if (err) { res.writeHead(404).end('no'); return; }
|
if (err) { res.writeHead(404).end('no'); return; }
|
||||||
const type = file.endsWith('.mp3') ? 'audio/mpeg'
|
const type = file.endsWith('.mp3') ? 'audio/mpeg'
|
||||||
: file.endsWith('.opml') ? 'text/x-opml' : 'application/xml';
|
: file.endsWith('.opml') ? 'text/x-opml'
|
||||||
|
// Artwork as an image, or /api/art refuses it as not one.
|
||||||
|
: file.endsWith('.jpg') ? 'image/jpeg'
|
||||||
|
: file.endsWith('.html') ? 'text/html' : 'application/xml';
|
||||||
res.writeHead(200, { 'content-type': type, 'content-length': body.length });
|
res.writeHead(200, { 'content-type': type, 'content-length': body.length });
|
||||||
res.end(body);
|
res.end(body);
|
||||||
});
|
});
|
||||||
|
|||||||
4
tests/ui/fixtures/site.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><title>A Site</title>
|
||||||
|
<link rel="alternate" type="application/rss+xml" href="/linked.xml">
|
||||||
|
</head><body>A site with a feed.</body></html>
|
||||||
@@ -6,6 +6,9 @@ const os = require('os');
|
|||||||
|
|
||||||
const root = path.join(os.tmpdir(), 'ipx-ui-test');
|
const root = path.join(os.tmpdir(), 'ipx-ui-test');
|
||||||
const TOKEN = 'testtokentesttokentesttoken12345'; // fixed, so tests need not scrape a log
|
const TOKEN = 'testtokentesttokentesttoken12345'; // fixed, so tests need not scrape a log
|
||||||
|
// The suite's own directory service (#205), and this daemon's token for it, which may do anything.
|
||||||
|
const DIRECTORY = 'http://127.0.0.1:8795';
|
||||||
|
const DIRECTORY_TOKEN = 'ipxd_testdirectorytoken';
|
||||||
|
|
||||||
// Called from playwright.config.js at load time, NOT as globalSetup: Playwright starts
|
// Called from playwright.config.js at load time, NOT as globalSetup: Playwright starts
|
||||||
// webServer *before* globalSetup, so a config written there does not exist yet when the
|
// webServer *before* globalSetup, so a config written there does not exist yet when the
|
||||||
@@ -29,13 +32,17 @@ download_dir = "${path.join(root, 'downloads')}"
|
|||||||
socket = "${path.join(root, 'ipx.sock')}"
|
socket = "${path.join(root, 'ipx.sock')}"
|
||||||
schedule = "every 60m"
|
schedule = "every 60m"
|
||||||
max_new_per_check = 1
|
max_new_per_check = 1
|
||||||
|
directory_url = "${DIRECTORY}"
|
||||||
|
directory_token = "${DIRECTORY_TOKEN}"
|
||||||
|
directory_share = true
|
||||||
|
|
||||||
[torrent]
|
[torrent]
|
||||||
enabled = false
|
enabled = false
|
||||||
|
|
||||||
[web]
|
[web]
|
||||||
enabled = true
|
enabled = true
|
||||||
bind = "127.0.0.1:8791"
|
# Behind Caddy, which serves the pages on 8791 and passes the API here (#203).
|
||||||
|
bind = "127.0.0.1:8796"
|
||||||
token = "${TOKEN}"
|
token = "${TOKEN}"
|
||||||
# The proxy path, for tests that send the header themselves: the daemon sees them at 127.0.0.1.
|
# The proxy path, for tests that send the header themselves: the daemon sees them at 127.0.0.1.
|
||||||
trusted_header = "X-Test-User"
|
trusted_header = "X-Test-User"
|
||||||
@@ -64,7 +71,16 @@ auto_download = false
|
|||||||
[feeds.paid-show]
|
[feeds.paid-show]
|
||||||
url = "http://127.0.0.1:8792/paid.xml?auth=secret123"
|
url = "http://127.0.0.1:8792/paid.xml?auth=secret123"
|
||||||
auto_download = false
|
auto_download = false
|
||||||
|
`);
|
||||||
|
fs.writeFileSync(path.join(root, 'directory.toml'), `
|
||||||
|
bind = "127.0.0.1:8795"
|
||||||
|
database_url_env = "DIRECTORY_DATABASE_URL"
|
||||||
|
|
||||||
|
[[tokens]]
|
||||||
|
name = "test"
|
||||||
|
access = "rwa"
|
||||||
|
token_sha256 = "${require('crypto').createHash('sha256').update(DIRECTORY_TOKEN).digest('hex')}"
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { prepare, root, TOKEN };
|
module.exports = { prepare, root, TOKEN, DIRECTORY, DIRECTORY_TOKEN };
|
||||||
|
|||||||
36
web/CHANGELOG.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Changelog: ipx-web
|
||||||
|
|
||||||
|
The page, and the container that serves it (Caddy). ipx's changelog is
|
||||||
|
[crates/ipx/CHANGELOG.md](../crates/ipx/CHANGELOG.md), which was the page's too until 0.12.0, and the
|
||||||
|
directory's [crates/directory/CHANGELOG.md](../crates/directory/CHANGELOG.md).
|
||||||
|
|
||||||
|
Its version is in `package.json`, and a release is tagged `web-vX.Y.Z`.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-10-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- With a directory token that may administer it, an admin has Check now and Remove from the directory on a Directory feed's page, and Servers and Blocked lists in Admin, Directory (#206).
|
||||||
|
- The page has a version of its own, which the logo's tooltip shows beside ipx's (#207).
|
||||||
|
- The page is served by its own container, ipx-web (Caddy), which passes the API to ipx on the same address, so a change to the page deploys without rebuilding ipx (#203).
|
||||||
|
- Admin, Directory sets the directory's address and this server's token, and whether to share, and says how the last share went (#205).
|
||||||
|
- A feed's settings have a Private switch, which keeps it out of the directory; on and locked for a feed whose address or login shows it is private (#205).
|
||||||
|
- ipx-web logs one line a request, without the request's headers or a sign-in token in the address (#210).
|
||||||
|
- ipx-web sends a trace span for each request when `WEB_TRACING=on`, continuing the page's trace into ipx (#213).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The Directory says when no directory is set up, and its counts are of everyone on every sharing server (#205).
|
||||||
|
- Add a feed no longer asks for a folder; a feed's folder is its title (#201).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 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
|
||||||
|
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/web-v1.0.0
|
||||||
119
web/Caddyfile
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# ipx-web (#203): the frontend as static files, and everything else passed to ipx, on one origin,
|
||||||
|
# so the session cookie, live updates and Cloudflare Access work as they did when ipx served it.
|
||||||
|
# IPX_UPSTREAM where ipx is, host:port (ipx:8099)
|
||||||
|
# WEB_BIND where to listen (:8080)
|
||||||
|
# WEB_TRACING on: a span for each request to OpenTelemetry (OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||||
|
# OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_SERVICE_NAME), continuing the page's traceparent
|
||||||
|
# and passing it to ipx, so one trace runs browser, Caddy, ipx (#213). Off: none,
|
||||||
|
# and no export failing every few seconds where there is nothing to send to.
|
||||||
|
# TUNNEL_PEER the one address whose Cf-Access-* headers are passed on: where cloudflared's
|
||||||
|
# requests come from. Nobody else's are, so nobody else can claim to be signed in
|
||||||
|
# by Cloudflare. Unset passes none.
|
||||||
|
{
|
||||||
|
admin off
|
||||||
|
auto_https off
|
||||||
|
# Everything else Caddy logs, its errors among them, which carry the request too: the same
|
||||||
|
# filter as the access log's below.
|
||||||
|
log default {
|
||||||
|
output stderr
|
||||||
|
format filter {
|
||||||
|
wrap json
|
||||||
|
fields {
|
||||||
|
request>uri query {
|
||||||
|
delete token
|
||||||
|
}
|
||||||
|
request>headers delete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(tracing-on) {
|
||||||
|
tracing {
|
||||||
|
span ipx-web
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(tracing-off) {
|
||||||
|
}
|
||||||
|
|
||||||
|
http://{$WEB_BIND::8080} {
|
||||||
|
import tracing-{$WEB_TRACING:off}
|
||||||
|
|
||||||
|
root * {$WEB_ROOT:/srv}
|
||||||
|
|
||||||
|
# One JSON line a request on stdout, for Loki and the dashboard's Page row (#210). Not the
|
||||||
|
# shared token a ?token= link carries, nor the request's headers, which hold the session
|
||||||
|
# cookie and Cloudflare Access's token.
|
||||||
|
log {
|
||||||
|
output stdout
|
||||||
|
format filter {
|
||||||
|
wrap json
|
||||||
|
fields {
|
||||||
|
request>uri query {
|
||||||
|
delete token
|
||||||
|
}
|
||||||
|
request>headers delete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
request_header @forged -Cf-Access-Jwt-Assertion
|
||||||
|
|
||||||
|
@api path /api/* /media/*
|
||||||
|
handle @api {
|
||||||
|
# Live updates are a stream: flushed as they come, never buffered.
|
||||||
|
reverse_proxy {$IPX_UPSTREAM:ipx:8099} {
|
||||||
|
flush_interval -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Signing in with the shared token in the address (?token=): ipx sets the session cookie and
|
||||||
|
# sends the browser on to the page without it.
|
||||||
|
@tokened {
|
||||||
|
path / /admin
|
||||||
|
query token=*
|
||||||
|
}
|
||||||
|
handle @tokened {
|
||||||
|
rewrite * /api/signin?{query}
|
||||||
|
reverse_proxy {$IPX_UPSTREAM:ipx:8099}
|
||||||
|
}
|
||||||
|
|
||||||
|
# The app and the admin page go to whoever ipx says may have them: a browser not signed in is
|
||||||
|
# sent to /login, anyone but an admin asking for /admin to the app.
|
||||||
|
@page path / /index.html /admin /admin.html /admin.js
|
||||||
|
handle @page {
|
||||||
|
forward_auth {$IPX_UPSTREAM:ipx:8099} {
|
||||||
|
uri /api/page-check
|
||||||
|
}
|
||||||
|
rewrite / /index.html
|
||||||
|
rewrite /admin /admin.html
|
||||||
|
header Cache-Control no-cache
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# The sign-in form, except for someone Cloudflare Access vouches for, who is sent to the app.
|
||||||
|
@login path /login /login.html
|
||||||
|
handle @login {
|
||||||
|
forward_auth {$IPX_UPSTREAM:ipx:8099} {
|
||||||
|
uri /api/login-check
|
||||||
|
}
|
||||||
|
rewrite * /login.html
|
||||||
|
header Cache-Control no-cache
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,27 +4,31 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="dark light">
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<meta name="theme-color" content="#0a1726">
|
||||||
<title>iPX admin</title>
|
<title>iPX admin</title>
|
||||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" data-src="app.css">
|
<link rel="stylesheet" data-src="app.css">
|
||||||
|
<script src="/boot.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="adminpage">
|
<body class="adminpage">
|
||||||
<!-- The server sends this page, and its script, to admins only. -->
|
<!-- Caddy sends this page, and its script, to admins only: it asks ipx first (/api/page-check). -->
|
||||||
<header id="topbar">
|
<header id="topbar">
|
||||||
<a class="btn ico" href="/" title="Back to iPX" aria-label="Back to iPX" data-icon="left"></a>
|
<a class="btn ico" href="/" title="Back to iPX" aria-label="Back to iPX" data-icon="left"></a>
|
||||||
<img class="logo" src="/logo.svg" alt="" width="26">
|
<img class="logo onlight" src="/logo.svg" alt="" width="26"><img class="logo ondark" src="/logo-dark.svg" alt="" width="26"><img class="logo classic" src="/icon.png" alt="" width="26">
|
||||||
<h1>Admin</h1>
|
<h1>Admin</h1>
|
||||||
<span class="grow"></span>
|
<span class="grow"></span>
|
||||||
<nav class="tabs" id="atabs">
|
<nav class="tabs" id="atabs">
|
||||||
<a href="#server" data-t="server">Server</a>
|
<a href="#server" data-t="server">Server</a>
|
||||||
<a href="#accounts" data-t="accounts">Accounts</a>
|
<a href="#accounts" data-t="accounts">Accounts</a>
|
||||||
|
<a href="#directory" data-t="directory">Directory</a>
|
||||||
<a href="#log" data-t="log">Log</a>
|
<a href="#log" data-t="log">Log</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main class="wrap plain" id="admin">
|
<main class="wrap plain" id="admin">
|
||||||
<section id="server" hidden></section>
|
<section id="server" hidden></section>
|
||||||
<section id="accounts" hidden></section>
|
<section id="accounts" hidden></section>
|
||||||
|
<section id="directory" hidden></section>
|
||||||
<section id="log" hidden></section>
|
<section id="log" hidden></section>
|
||||||
</main>
|
</main>
|
||||||
<div id="toasts"></div>
|
<div id="toasts"></div>
|
||||||
|
|||||||
413
web/app.css
@@ -2,44 +2,44 @@
|
|||||||
anyone else. One variable file covers every weight used here; italics are synthesized. Classic
|
anyone else. One variable file covers every weight used here; italics are synthesized. Classic
|
||||||
keeps Lucida Grande, the 2004 app's face. */
|
keeps Lucida Grande, the 2004 app's face. */
|
||||||
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
|
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
|
||||||
/* Palette taken from the 2004 iPodderX icon: the silver device body, the blue
|
/* Palette taken from the logo (web/logo.svg, logo-dark.svg): its navy ground, the tuning scale's
|
||||||
screen, and the amber EQ bars. Hex values in comments are sampled straight from it. */
|
blue and the orange needle. The dark half follows logo-dark.svg. */
|
||||||
:root {
|
:root {
|
||||||
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */
|
--bg:#0a1726; /* the dark logo's navy (#0c2238), taken down */
|
||||||
--panel:#151c27;
|
--panel:#0f1f31;
|
||||||
--panel2:#1c2431;
|
--panel2:#15283d;
|
||||||
--raise:#25303f;
|
--raise:#1d3450;
|
||||||
--line:#2c3849;
|
--line:#264060;
|
||||||
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
--fg:#f3f7fb;
|
||||||
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
--dim:#a3b6ca;
|
||||||
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
|
--faint:#8a9fb5;
|
||||||
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
--accent:#8fc2ea; /* #8FC2EA the dark logo's scale */
|
||||||
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
--accent2:#ff6a1a; /* #FF6A1A the dark logo's needle */
|
||||||
--ink:#0e131b; /* text on an accent fill */
|
--ink:#0a1726; /* text on an accent fill */
|
||||||
--good:#6fbf8b;
|
--good:#6fbf8b;
|
||||||
--warn:#f49e2c; /* the amber doubles as the pending colour */
|
--warn:#f5a524; /* amber, apart from the needle's orange, for pending */
|
||||||
--bad:#e2705f;
|
--bad:#ff6b7a; /* pushed towards pink, apart from the needle */
|
||||||
--shadow:0 8px 28px rgba(6,10,16,.55);
|
--shadow:0 8px 28px rgba(4,10,20,.55);
|
||||||
}
|
}
|
||||||
/* Every other theme overrides the same variables, under data-theme and data-mode. The page's
|
/* Every other theme overrides the same variables, under data-theme and data-mode. The page's
|
||||||
script sets data-mode to light or dark, working Auto out from the system, so each theme has
|
script sets data-mode to light or dark, working Auto out from the system, so each theme has
|
||||||
one block per variant here and none needs repeating under a media query. */
|
one block per variant here and none needs repeating under a media query. */
|
||||||
:root[data-theme="modern"][data-mode="light"] {
|
:root[data-theme="modern"][data-mode="light"] {
|
||||||
--bg:#f2f4f7;
|
--bg:#eef5fb; /* the light logo's sky (#DFF0FB), paler */
|
||||||
--panel:#ffffff; /* #FFFFFF device body */
|
--panel:#ffffff;
|
||||||
--panel2:#e9edf3;
|
--panel2:#e4eff9;
|
||||||
--raise:#dde3ec;
|
--raise:#d5e6f5;
|
||||||
--line:#d6d6d6; /* #D6D6D6 device edge */
|
--line:#c6d9ea;
|
||||||
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
--fg:#10233a;
|
||||||
--dim:#606060; /* #606060 */
|
--dim:#46596e;
|
||||||
--faint:#6b6b6b; /* between the icon's #929292 and #606060, to clear AA on the page ground */
|
--faint:#56697e;
|
||||||
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
--accent:#2f6aa0; /* #2F6AA0 the logo's scale */
|
||||||
--accent2:#985e0a; /* the EQ amber, taken down until white on it clears AA */
|
--accent2:#c43e00; /* the needle (#FF5500), taken down until white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#2f7d4f;
|
--good:#2f7d4f;
|
||||||
--warn:#985e0a; /* the same amber; the lighter one failed AA as text */
|
--warn:#985e0a; /* amber, apart from the needle's orange; lighter failed AA as text */
|
||||||
--bad:#b3402f;
|
--bad:#b3263a; /* pushed towards crimson, apart from the needle */
|
||||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
--shadow:0 8px 28px rgba(47,106,160,.14);
|
||||||
}
|
}
|
||||||
/* Dracula, and Alucard, its light half, from draculatheme.com/spec. The spec's comment colour
|
/* Dracula, and Alucard, its light half, from draculatheme.com/spec. The spec's comment colour
|
||||||
(#6272A4, #6C664B) is too faint for small text on its own background, so --faint is lifted. */
|
(#6272A4, #6C664B) is too faint for small text on its own background, so --faint is lifted. */
|
||||||
@@ -426,6 +426,7 @@
|
|||||||
--glass-hi:rgba(255,255,255,.26);
|
--glass-hi:rgba(255,255,255,.26);
|
||||||
--glass-lo:rgba(255,255,255,.10);
|
--glass-lo:rgba(255,255,255,.10);
|
||||||
--glass-sheen:rgba(255,255,255,.07);
|
--glass-sheen:rgba(255,255,255,.07);
|
||||||
|
--glass-bar:#191e34; /* the top bar on a phone: what the glass one showed, on average */
|
||||||
--wash1:rgba(64,120,255,.30);
|
--wash1:rgba(64,120,255,.30);
|
||||||
--wash2:rgba(175,82,222,.24);
|
--wash2:rgba(175,82,222,.24);
|
||||||
--wash3:rgba(48,176,199,.20);
|
--wash3:rgba(48,176,199,.20);
|
||||||
@@ -450,6 +451,7 @@
|
|||||||
--glass-hi:rgba(255,255,255,.9);
|
--glass-hi:rgba(255,255,255,.9);
|
||||||
--glass-lo:rgba(255,255,255,.45);
|
--glass-lo:rgba(255,255,255,.45);
|
||||||
--glass-sheen:rgba(255,255,255,.5);
|
--glass-sheen:rgba(255,255,255,.5);
|
||||||
|
--glass-bar:#f6f7ff;
|
||||||
--wash1:rgba(64,120,255,.22);
|
--wash1:rgba(64,120,255,.22);
|
||||||
--wash2:rgba(175,82,222,.16);
|
--wash2:rgba(175,82,222,.16);
|
||||||
--wash3:rgba(48,176,199,.18);
|
--wash3:rgba(48,176,199,.18);
|
||||||
@@ -458,6 +460,10 @@
|
|||||||
/* A rule that sets display beats the UA's [hidden], and several below do. */
|
/* A rule that sets display beats the UA's [hidden], and several below do. */
|
||||||
[hidden]{display:none!important}
|
[hidden]{display:none!important}
|
||||||
html,body{height:100%}
|
html,body{height:100%}
|
||||||
|
/* What iOS paints behind the status bar where nothing sticky is at the top edge, as in the
|
||||||
|
reader on a phone, which brings its own. With no colour of its own, the root went black or
|
||||||
|
white there instead (#147). */
|
||||||
|
html{background-color:var(--bg)}
|
||||||
body{
|
body{
|
||||||
margin:0;background:var(--bg);color:var(--fg);
|
margin:0;background:var(--bg);color:var(--fg);
|
||||||
font:14.5px/1.55 Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
font:14.5px/1.55 Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||||
@@ -493,11 +499,14 @@ a{color:var(--accent)}
|
|||||||
background:var(--panel);border-right:1px solid var(--line);
|
background:var(--panel);border-right:1px solid var(--line);
|
||||||
display:flex;flex-direction:column;min-height:0;
|
display:flex;flex-direction:column;min-height:0;
|
||||||
}
|
}
|
||||||
.brand{display:flex;align-items:center;gap:9px;padding:14px 14px 10px}
|
#applogo{display:flex;flex:none}
|
||||||
.brand .logo{
|
#applogo img{width:28px;height:28px;border-radius:7px}
|
||||||
width:30px;height:30px;flex:none;border-radius:7px;
|
/* The logo 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. */
|
||||||
.brand h1{font-size:16px;margin:0;font-weight:650;letter-spacing:-.01em;color:var(--fg);flex:1}
|
:root[data-mode="light"] .logo.ondark,:root:not([data-mode="light"]) .logo.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}
|
||||||
.iconbtn{
|
.iconbtn{
|
||||||
width:30px;height:30px;border-radius:8px;display:grid;place-items:center;
|
width:30px;height:30px;border-radius:8px;display:grid;place-items:center;
|
||||||
color:var(--dim);flex:none;
|
color:var(--dim);flex:none;
|
||||||
@@ -522,6 +531,10 @@ a{color:var(--accent)}
|
|||||||
padding-top:calc(7px + var(--safe-t));padding-bottom:7px;
|
padding-top:calc(7px + var(--safe-t));padding-bottom:7px;
|
||||||
padding-left:calc(12px + var(--safe-l));padding-right:calc(12px + var(--safe-r));
|
padding-left:calc(12px + var(--safe-l));padding-right:calc(12px + var(--safe-r));
|
||||||
background:var(--panel);border-bottom:1px solid var(--line);
|
background:var(--panel);border-bottom:1px solid var(--line);
|
||||||
|
/* Nothing scrolls under it: it is sticky only because iOS 26 paints the strip behind the status
|
||||||
|
bar and the Dynamic Island in the colour of a sticky or fixed bar at the top edge, and ignores
|
||||||
|
theme-color, so the bar now runs all the way up behind the island (#147). */
|
||||||
|
position:sticky;top:0;
|
||||||
}
|
}
|
||||||
#topbar .grow{flex:1}
|
#topbar .grow{flex:1}
|
||||||
#topbar #epSearch{width:260px;flex:none}
|
#topbar #epSearch{width:260px;flex:none}
|
||||||
@@ -548,7 +561,7 @@ a{color:var(--accent)}
|
|||||||
.who button:hover{color:var(--fg)}
|
.who button:hover{color:var(--fg)}
|
||||||
.sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
|
.sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
|
||||||
.sidefoot i{font-style:normal;opacity:.75}
|
.sidefoot i{font-style:normal;opacity:.75}
|
||||||
.searchwrap{padding:0 12px 8px}
|
.searchwrap{padding:12px 12px 8px}
|
||||||
input[type=search],input[type=text],input[type=password],input[type=number],select{
|
input[type=search],input[type=text],input[type=password],input[type=number],select{
|
||||||
width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);
|
width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);
|
||||||
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
|
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
|
||||||
@@ -566,7 +579,14 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
/* A show sits under its folder's title, a size down, so an open folder reads as one. */
|
/* A show sits under its folder's title, a size down, so an open folder reads as one. */
|
||||||
.feed.child{margin-left:11px}
|
.feed.child{margin-left:11px}
|
||||||
/* Pinned feeds, at the top of the list: a small pin before the name, and a rule under the last. */
|
/* Pinned feeds, at the top of the list: a small pin before the name, and a rule under the last. */
|
||||||
.feed .fpin .i{width:10px;height:10px;margin-right:5px;vertical-align:-1px;color:var(--accent)}
|
/* Pinned: a disc on the artwork's corner, as a failing feed's mark is, in the accent and the ink
|
||||||
|
the theme pairs with it. A feed both pinned and failing keeps the error below, the pin above. */
|
||||||
|
.fpin{
|
||||||
|
position:absolute;right:-5px;bottom:-5px;width:17px;height:17px;border-radius:50%;
|
||||||
|
display:grid;place-items:center;background:var(--accent);color:var(--ink);border:2px solid var(--bg);
|
||||||
|
}
|
||||||
|
.fpin .i{width:9px;height:9px}
|
||||||
|
.feed.err .fpin{bottom:auto;top:-5px}
|
||||||
.feed.lastpin{margin-bottom:9px}
|
.feed.lastpin{margin-bottom:9px}
|
||||||
.feed.lastpin::after{content:"";position:absolute;left:8px;right:8px;bottom:-5px;border-bottom:1px solid var(--line)}
|
.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}
|
.feed.child .art{width:28px;height:28px;font-size:11px}
|
||||||
@@ -586,11 +606,20 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite;
|
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes ipxspin{to{transform:rotate(360deg)}}
|
@keyframes ipxspin{to{transform:rotate(360deg)}}
|
||||||
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle,
|
body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,body.scan-any #scanAll .i{
|
||||||
a child's included, which is why it moves left by the child's indent. */
|
animation:ipxspin 1s linear infinite}
|
||||||
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
|
/* A feed's error mark: a solid disc on the artwork's corner, ringed in the page ground so it
|
||||||
.ferr .i{width:12px;height:12px}
|
stands off any cover. The glyph is cut out of it in --bg, which is the dark against the light
|
||||||
.feed.child .ferr{left:-27px}
|
--bad of a dark theme and the light against the deep one of a light theme. */
|
||||||
|
.fart{position:relative;flex:none;display:grid}
|
||||||
|
.ferr{
|
||||||
|
position:absolute;right:-5px;bottom:-5px;width:17px;height:17px;border-radius:50%;
|
||||||
|
display:grid;place-items:center;background:var(--bad);color:var(--bg);border:2px solid var(--bg);
|
||||||
|
}
|
||||||
|
.ferr .i{width:9px;height:9px}
|
||||||
|
.feed.err .txt small{color:var(--bad)}
|
||||||
|
/* Failing for a day or more: the cover goes grey, a show gone off the air. */
|
||||||
|
.feed.failing .fart>.art{filter:grayscale(1);opacity:.45}
|
||||||
.chev .i{width:12px;height:12px;transition:transform .12s}
|
.chev .i{width:12px;height:12px;transition:transform .12s}
|
||||||
.chev[aria-expanded="true"] .i{transform:rotate(90deg)}
|
.chev[aria-expanded="true"] .i{transform:rotate(90deg)}
|
||||||
.childlist{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-top:10px}
|
.childlist{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-top:10px}
|
||||||
@@ -621,24 +650,102 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
/* Quiet buttons: a filled one on every row outshouted the row that is playing. */
|
/* Quiet buttons: a filled one on every row outshouted the row that is playing. */
|
||||||
#listening [data-a=play]{color:var(--accent)}
|
#listening [data-a=play]{color:var(--accent)}
|
||||||
#listening [data-a=remove]{color:var(--faint)}
|
#listening [data-a=remove]{color:var(--faint)}
|
||||||
/* Directory's filters: .tabs for what a feed is, as the item filters are, and chips for what it
|
/* The Directory, laid out as Apple's is: a large title, then sections. Every page carries what
|
||||||
is about. A picked chip is underlined in --accent2, as the download bar and the now-playing EQ
|
kind of feed (.tabs, as the item filters are) in its title row. */
|
||||||
are; a .badge's fill already means unread in the sidebar. */
|
.dhead{margin-bottom:6px}
|
||||||
.dirbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-top:4px}
|
.dback{display:inline-flex;align-items:center;gap:5px;margin:0 0 8px -5px;padding:3px 5px;border-radius:6px;font-size:13px;color:var(--accent)}
|
||||||
.dirbar .tabs{flex:none}
|
.dback .i{width:11px;height:11px}
|
||||||
.chips{display:flex;flex-wrap:wrap;gap:2px 6px;flex:1 1 0;min-width:0}
|
.dback:hover{background:var(--raise)}
|
||||||
.chips button{flex:none;padding:4px 6px;font-size:13px;color:var(--dim);white-space:nowrap;border-bottom:2px solid transparent}
|
.dtitle{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 18px}
|
||||||
.chips button:hover{color:var(--fg)}
|
.dtitle h2{margin:0;font-size:30px;line-height:1.15;font-weight:700;letter-spacing:-.02em;overflow-wrap:anywhere}
|
||||||
.chips button[aria-pressed="true"]{color:var(--fg);border-bottom-color:var(--accent2)}
|
.dhead .sub{margin:5px 0 0;color:var(--dim);font-size:13px;font-variant-numeric:tabular-nums}
|
||||||
|
.dsec{margin-top:28px}
|
||||||
|
.dsech{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px}
|
||||||
|
.dsech h3{margin:0;font-size:19px;line-height:1.25;font-weight:650;letter-spacing:-.01em}
|
||||||
|
.dall{font-size:13px;color:var(--accent);flex:none}
|
||||||
|
.dall:hover{text-decoration:underline}
|
||||||
|
/* The most subscribed, numbered because they are ranked: down one column, then the next. */
|
||||||
|
.chart{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(var(--rows,5),auto);grid-auto-flow:column;column-gap:32px}
|
||||||
|
.rank{display:grid;grid-template-columns:22px 52px minmax(0,1fr) auto;align-items:center;gap:12px;padding:8px 0;border-bottom:1px solid var(--line)}
|
||||||
|
.rank .no{font-size:17px;font-weight:650;color:var(--faint);text-align:right;font-variant-numeric:tabular-nums}
|
||||||
|
.rank .art{width:52px;height:52px;font-size:16px}
|
||||||
|
.rank .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:14px;line-height:1.3}
|
||||||
|
.rank .txt small{display:block;margin-top:2px;color:var(--faint);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
/* A category: its name, and its three most subscribed shows' covers fanned in the corner, so the
|
||||||
|
shows are what colour it. Not tinted: a tint by the name's hash meant nothing, and the five that
|
||||||
|
drew --good read as yours, which green means everywhere else (#131). The fan opens a little
|
||||||
|
under the pointer, the only thing on the page that moves. */
|
||||||
|
.cats{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:12px}
|
||||||
|
.cat{
|
||||||
|
position:relative;overflow:hidden;height:116px;padding:13px 15px;border-radius:12px;text-align:left;
|
||||||
|
display:flex;flex-direction:column;align-items:flex-start;
|
||||||
|
background:var(--panel);border:1px solid var(--line);
|
||||||
|
}
|
||||||
|
.cat:hover{border-color:var(--accent)}
|
||||||
|
/* The whole width: the fan sits below two lines of name, and a narrower name broke Government
|
||||||
|
in two in the wider faces, Classic's and Glass's. */
|
||||||
|
.cat b{font-size:16px;line-height:1.2;font-weight:650;color:var(--fg);overflow-wrap:break-word}
|
||||||
|
.cat small{margin-top:3px;font-size:12px;color:var(--dim);font-variant-numeric:tabular-nums}
|
||||||
|
.cat .fan{position:absolute;right:14px;bottom:-12px;width:112px;height:80px;pointer-events:none}
|
||||||
|
.cat .fan .art{
|
||||||
|
position:absolute;bottom:0;width:62px;height:62px;font-size:16px;border-radius:8px;box-shadow:0 3px 10px rgba(0,0,0,.3);
|
||||||
|
/* Each cover on a layer of its own before the pointer arrives. Given one only as the fan began
|
||||||
|
to open, the browser drew the covers again on the first frames, and again on the way back,
|
||||||
|
and the fan opened in a jerk (#132). All three move, for as long as the eye can follow, as
|
||||||
|
one motion that settles rather than stops. */
|
||||||
|
will-change:transform;
|
||||||
|
transition:rotate .45s cubic-bezier(.2,.7,.2,1),translate .45s cubic-bezier(.2,.7,.2,1);
|
||||||
|
}
|
||||||
|
.cat .fan .art:nth-last-child(1){right:0;rotate:8deg;translate:0 -12px}
|
||||||
|
.cat .fan .art:nth-last-child(2){right:25px;rotate:-3deg;translate:0 -4px}
|
||||||
|
.cat .fan .art:nth-last-child(3){right:50px;rotate:-13deg;translate:0 4px}
|
||||||
|
.cat:hover .fan .art:nth-last-child(1){rotate:13deg;translate:5px -17px}
|
||||||
|
.cat:hover .fan .art:nth-last-child(2){rotate:-2deg;translate:0 -8px}
|
||||||
|
.cat:hover .fan .art:nth-last-child(3){rotate:-19deg;translate:-7px 3px}
|
||||||
|
/* A category's subcategories, each with how many it holds, then the order. A picked one is
|
||||||
|
drawn as a picked tab is. */
|
||||||
|
.dtools{display:flex;align-items:center;gap:10px 14px;margin-bottom:14px}
|
||||||
|
.pills{display:flex;flex-wrap:wrap;gap:6px;flex:1 1 0;min-width:0}
|
||||||
|
.pills button{flex:none;padding:4px 11px;border:1px solid var(--line);border-radius:999px;font-size:13px;color:var(--dim);white-space:nowrap}
|
||||||
|
.pills button small{margin-left:6px;color:var(--faint);font-size:11.5px;font-variant-numeric:tabular-nums}
|
||||||
|
.pills button:hover{border-color:var(--accent);color:var(--fg)}
|
||||||
|
.pills button.on{background:var(--raise);border-color:var(--raise);color:var(--fg);font-weight:500}
|
||||||
|
.dtools .grow{flex:1}
|
||||||
|
#dirsort{flex:none;width:auto;padding:4px 6px;font-size:13px}
|
||||||
/* Square cover art, title underneath: podcast art is made to be known at a glance. The subscribe
|
/* Square cover art, title underneath: podcast art is made to be known at a glance. The subscribe
|
||||||
button stays visible, since a button that only shows on hover cannot be reached on a phone. */
|
button stays visible, since a button that only shows on hover cannot be reached on a phone. */
|
||||||
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:18px 14px;margin-top:14px}
|
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:18px 14px}
|
||||||
.tiles>.hint{grid-column:1/-1}
|
.tiles>.hint{grid-column:1/-1}
|
||||||
.tile{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 2px;align-items:start;cursor:pointer}
|
.tile{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 2px;align-items:start}
|
||||||
.tile .art{grid-column:1/-1;width:100%;height:auto;aspect-ratio:1;font-size:34px}
|
.tile .art{grid-column:1/-1;width:100%;height:auto;aspect-ratio:1;font-size:34px}
|
||||||
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
|
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
|
||||||
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
|
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
|
||||||
.tile:hover .txt b{color:var(--accent)}
|
/* Every one opens: yours its feed, the rest their page here. */
|
||||||
|
.tile,.rank{cursor:pointer}
|
||||||
|
.tile:hover .txt b,.rank:hover .txt b{color:var(--accent)}
|
||||||
|
/* A feed's page before you subscribe, as Apple's show page: the cover large, what it is and who
|
||||||
|
here reads it beside it, then what it has published lately. */
|
||||||
|
.show{display:flex;gap:22px;align-items:flex-end;margin-top:4px}
|
||||||
|
.show>.art{width:180px;height:180px;font-size:48px;border-radius:12px;box-shadow:var(--shadow)}
|
||||||
|
.show .meta{min-width:0;flex:1;display:flex;flex-direction:column;align-items:flex-start;gap:5px}
|
||||||
|
.show h2{margin:0 0 2px;font-size:30px;line-height:1.15;font-weight:700;letter-spacing:-.02em;overflow-wrap:anywhere}
|
||||||
|
.show .sub{margin:0;color:var(--dim);font-size:13px;font-variant-numeric:tabular-nums}
|
||||||
|
.show .dfail{color:var(--bad)}
|
||||||
|
.show .acts{margin-top:8px}
|
||||||
|
.dlink{padding:0;font-size:13px;color:var(--accent);text-align:left}
|
||||||
|
.dlink:hover{text-decoration:underline}
|
||||||
|
/* What the feed says it is, three lines of it until More. */
|
||||||
|
.about{max-width:760px;margin-top:18px}
|
||||||
|
.about p{margin:0;color:var(--dim);font-size:14px;line-height:1.55;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;overflow-wrap:anywhere}
|
||||||
|
.about.open p{display:block}
|
||||||
|
.about .dlink{margin-top:4px}
|
||||||
|
.latest{max-width:760px}
|
||||||
|
.lep{padding:12px 0;border-bottom:1px solid var(--line)}
|
||||||
|
.lep b{display:block;font-size:15px;font-weight:600;line-height:1.35;overflow-wrap:anywhere}
|
||||||
|
.lep b a{color:var(--fg);text-decoration:none}
|
||||||
|
.lep b a:hover{color:var(--accent)}
|
||||||
|
.lep p{margin:4px 0 0;color:var(--dim);font-size:13px;line-height:1.5;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden}
|
||||||
|
.lep small{display:flex;align-items:center;gap:7px;margin-top:6px;color:var(--faint);font-size:12px;font-variant-numeric:tabular-nums}
|
||||||
/* Outlined, not filled: on --raise the warning and error colours fell short of AA in most light
|
/* Outlined, not filled: on --raise the warning and error colours fell short of AA in most light
|
||||||
themes, where on the row's own ground they clear it. */
|
themes, where on the row's own ground they clear it. */
|
||||||
.tag{
|
.tag{
|
||||||
@@ -667,7 +774,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
/* A selected row is --raise too, and the zero count's pill vanished into it. */
|
/* A selected row is --raise too, and the zero count's pill vanished into it. */
|
||||||
.sel .badge.zero{background:var(--bg)}
|
.sel .badge.zero{background:var(--bg)}
|
||||||
/* Counts, sizes and times that change in place should not jiggle the text around them. */
|
/* Counts, sizes and times that change in place should not jiggle the text around them. */
|
||||||
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
|
.badge,.feed small,.childrow small,.tile small,.rank small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
/* ---------- main ---------- */
|
/* ---------- main ---------- */
|
||||||
#main{overflow:hidden;min-height:0;min-width:0;display:flex;flex-direction:column}
|
#main{overflow:hidden;min-height:0;min-width:0;display:flex;flex-direction:column}
|
||||||
@@ -689,11 +796,21 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
reloads the page, from starting on top of it. */
|
reloads the page, from starting on top of it. */
|
||||||
#pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden;
|
#pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden;
|
||||||
font-size:12px;color:var(--faint)}
|
font-size:12px;color:var(--faint)}
|
||||||
|
/* What a pull started, for a couple of seconds after letting go, under the top bar. */
|
||||||
|
#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)}
|
||||||
|
#pullspin::before{content:"";width:12px;height:12px;border-radius:50%;
|
||||||
|
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite}
|
||||||
/* The selected item's files, beside the list, as the original's Files pane was. */
|
/* The selected item's files, beside the list, as the original's Files pane was. */
|
||||||
#files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)}
|
#files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)}
|
||||||
/* Nothing selected, or an item with no files: the list takes the width. */
|
/* Nothing selected, or an item with no files: the list takes the width. */
|
||||||
#split:has(>#files[hidden]){grid-template-columns:minmax(0,1fr) 0}
|
#split:has(>#files[hidden]){grid-template-columns:minmax(0,1fr) 0}
|
||||||
#files .encbox{margin-top:8px}
|
/* One row in the narrow pane: four buttons and the size wrapped the last button under the rest.
|
||||||
|
The size gives way first, with an ellipsis. */
|
||||||
|
#files .encbox{margin-top:8px;flex-wrap:nowrap;gap:6px;padding:10px}
|
||||||
|
#files .encbox .meta{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
#files .encbox .btn.ico{padding:4px 7px;min-width:0;flex:none}
|
||||||
#files .empty{padding:24px 0}
|
#files .empty{padding:24px 0}
|
||||||
.fhd{font-size:12px;color:var(--faint)}
|
.fhd{font-size:12px;color:var(--faint)}
|
||||||
#grab{grid-area:grab;cursor:row-resize;background:var(--line)}
|
#grab{grid-area:grab;cursor:row-resize;background:var(--line)}
|
||||||
@@ -702,6 +819,9 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
.dt{font-size:18px;font-weight:650;margin:0 0 5px;line-height:1.3;overflow-wrap:anywhere}
|
.dt{font-size:18px;font-weight:650;margin:0 0 5px;line-height:1.3;overflow-wrap:anywhere}
|
||||||
.dmeta{color:var(--faint);font-size:12.5px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
|
.dmeta{color:var(--faint);font-size:12.5px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
|
||||||
.dmeta .btn{padding:3px 9px;font-size:12px}
|
.dmeta .btn{padding:3px 9px;font-size:12px}
|
||||||
|
/* The reader's buttons go as one: as items of the line itself, a long feed name took them onto
|
||||||
|
the next line one at a time (#146). */
|
||||||
|
.dacts{display:flex;gap:inherit;flex:none}
|
||||||
#detail .encbox{margin:0 0 10px}
|
#detail .encbox{margin:0 0 10px}
|
||||||
.dbody{font-size:14.5px;line-height:1.65;overflow-wrap:anywhere;color:var(--fg)}
|
.dbody{font-size:14.5px;line-height:1.65;overflow-wrap:anywhere;color:var(--fg)}
|
||||||
.dbody img{max-width:100%;height:auto;border-radius:6px}
|
.dbody img{max-width:100%;height:auto;border-radius:6px}
|
||||||
@@ -718,12 +838,10 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
.fhead .sub{color:var(--dim);font-size:13px;margin-bottom:8px}
|
.fhead .sub{color:var(--dim);font-size:13px;margin-bottom:8px}
|
||||||
.fhead .sub a{color:var(--dim);text-decoration:none}
|
.fhead .sub a{color:var(--dim);text-decoration:none}
|
||||||
.fhead .sub a:hover{color:var(--accent)}
|
.fhead .sub a:hover{color:var(--accent)}
|
||||||
.acts{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-top:auto}
|
.acts{display:flex;gap:7px;align-items:center;margin-top:auto}
|
||||||
.acts .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:999px}
|
.acts .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:999px}
|
||||||
.acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7}
|
.acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7}
|
||||||
.acts .btn.primary i{opacity:.9}
|
|
||||||
.acts .btn:hover{background:var(--raise)}
|
.acts .btn:hover{background:var(--raise)}
|
||||||
.acts .btn.primary:hover{background:var(--accent)}
|
|
||||||
.btn{
|
.btn{
|
||||||
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
|
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
|
||||||
padding:6px 12px;font-size:13px;
|
padding:6px 12px;font-size:13px;
|
||||||
@@ -770,8 +888,13 @@ body.adminpage #topbar{position:sticky;top:0;z-index:5}
|
|||||||
body.adminpage #topbar h1{font-size:16px;margin:0;font-weight:650}
|
body.adminpage #topbar h1{font-size:16px;margin:0;font-weight:650}
|
||||||
body.adminpage #topbar .logo{border-radius:6px}
|
body.adminpage #topbar .logo{border-radius:6px}
|
||||||
body.adminpage #topbar > a.btn{text-decoration:none}
|
body.adminpage #topbar > a.btn{text-decoration:none}
|
||||||
|
/* The link to Admin, for admins only: the page is the same for everyone now (#203), and the head
|
||||||
|
script marks an admin's from the last visit before the first frame. */
|
||||||
|
html:not([data-admin]) #topbar a#admin{display:none}
|
||||||
body.adminpage #admin{max-width:780px;margin:0 auto}
|
body.adminpage #admin{max-width:780px;margin:0 auto}
|
||||||
body.adminpage #admin h2{font-size:18px;margin:0 0 12px}
|
body.adminpage #admin h2{font-size:18px;margin:0 0 12px}
|
||||||
|
/* The directory's Servers and Blocked follow its Save and each other's rows (#206). */
|
||||||
|
body.adminpage #dadmin h2{margin-top:32px}
|
||||||
body.adminpage #log{max-width:none}
|
body.adminpage #log{max-width:none}
|
||||||
.urow{padding:9px 0;border-bottom:1px solid var(--line)}
|
.urow{padding:9px 0;border-bottom:1px solid var(--line)}
|
||||||
.toolbar .grow{flex:1;min-width:150px;max-width:320px}
|
.toolbar .grow{flex:1;min-width:150px;max-width:320px}
|
||||||
@@ -811,7 +934,15 @@ body.adminpage #log{max-width:none}
|
|||||||
icon, so it spilled 3px right of centre and off line with its heading. */
|
icon, so it spilled 3px right of centre and off line with its heading. */
|
||||||
.ep .st,.ep .fl{width:22px;height:22px;padding:0;border-radius:5px;display:grid;place-items:center;font-size:11px;color:var(--accent2)}
|
.ep .st,.ep .fl{width:22px;height:22px;padding:0;border-radius:5px;display:grid;place-items:center;font-size:11px;color:var(--accent2)}
|
||||||
.ep .fl{color:var(--faint);font-size:13px}
|
.ep .fl{color:var(--faint);font-size:13px}
|
||||||
.ep .fl.on{color:var(--fg)}
|
/* Pinned: the pinned feed's disc (.fpin), the accent and its ink, drawn inside the 22px button so
|
||||||
|
the row does not move when it changes. */
|
||||||
|
.ep .fl.on,.ep .fl.on:hover{color:var(--ink);background:radial-gradient(circle,var(--accent) 8.5px,transparent 9px)}
|
||||||
|
.ep .fl.on .i{width:9px;height:9px}
|
||||||
|
/* The reader's and a feed's pin button, and the toolbar's, pinned: filled with the accent, as the
|
||||||
|
disc is. A solid pin on the button's own grey read the same as an outlined one (#148). */
|
||||||
|
.btn.ico[data-a=pin][aria-pressed=true],.btn.ico[data-a=pin][aria-pressed=true]:hover,
|
||||||
|
.btn.ico[data-a=flag][aria-pressed=true],.btn.ico[data-a=flag][aria-pressed=true]:hover,
|
||||||
|
#tbFlag[aria-pressed=true],#tbFlag[aria-pressed=true]:hover{background:var(--accent);border-color:var(--accent);color:var(--ink)}
|
||||||
/* The icon's EQ bars mark what is playing: standing still, and moving only while it plays. A
|
/* The icon's EQ bars mark what is playing: standing still, and moving only while it plays. A
|
||||||
paused animation was tried first; the frames it held were a pixel apart and read as dots. */
|
paused animation was tried first; the frames it held were a pixel apart and read as dots. */
|
||||||
.eq{display:inline-flex;align-items:flex-end;gap:2px;width:13px;height:12px;flex:none}
|
.eq{display:inline-flex;align-items:flex-end;gap:2px;width:13px;height:12px;flex:none}
|
||||||
@@ -824,6 +955,8 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
|
|||||||
@keyframes eq{from{transform:scaleY(.35)}}
|
@keyframes eq{from{transform:scaleY(.35)}}
|
||||||
.ep .st:hover,.ep .fl:hover{background:var(--raise)}
|
.ep .st:hover,.ep .fl:hover{background:var(--raise)}
|
||||||
.ep .t{font-weight:600;font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.ep .t{font-weight:600;font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
/* An item with no title shows its opening words: set as text, not as a heading. */
|
||||||
|
.ep .t.notitle{font-weight:400}
|
||||||
.ep.read .t{color:var(--dim);font-weight:500}
|
.ep.read .t{color:var(--dim);font-weight:500}
|
||||||
.ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px}
|
.ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px}
|
||||||
.ep .line:empty{display:none}
|
.ep .line:empty{display:none}
|
||||||
@@ -924,10 +1057,100 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
.inline{display:flex;gap:6px;align-items:stretch}
|
.inline{display:flex;gap:6px;align-items:stretch}
|
||||||
.inline input{flex:1;min-width:0}
|
.inline input{flex:1;min-width:0}
|
||||||
.inline .btn{white-space:nowrap;flex:none}
|
.inline .btn{white-space:nowrap;flex:none}
|
||||||
|
/* An API token in Settings: its name and dates, and the button that revokes it. */
|
||||||
|
.tokrow{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}
|
||||||
|
.tokrow .hint{display:block}
|
||||||
.inline select{flex:none;width:auto}
|
.inline select{flex:none;width:auto}
|
||||||
.inline input[type=number]{flex:none;width:90px}
|
.inline input[type=number]{flex:none;width:90px}
|
||||||
.check input{width:16px;height:16px;accent-color:var(--accent)}
|
.check input{width:16px;height:16px;accent-color:var(--accent)}
|
||||||
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
||||||
|
|
||||||
|
/* ---------- a feed's settings (#167) ---------- */
|
||||||
|
/* A frame of its own: the body scrolls and Save stays at the foot, where on a phone it had been
|
||||||
|
three screens down. */
|
||||||
|
.card.fset{padding:0;display:flex;flex-direction:column;overflow:hidden;width:min(520px,100%)}
|
||||||
|
.fsetbody{overflow:auto;min-height:0;padding:22px 24px 24px}
|
||||||
|
.card.fset .fsetfoot{flex:none;margin:0;padding:12px 24px;border-top:1px solid var(--line)}
|
||||||
|
.fsethead{display:flex;gap:14px;align-items:center;margin-bottom:18px}
|
||||||
|
.fsethead .art{width:56px;height:56px;border-radius:12px;font-size:18px}
|
||||||
|
.fsethead h3{margin:0;font-size:19px;line-height:1.25;letter-spacing:-.01em;overflow-wrap:anywhere}
|
||||||
|
.fsethead p{margin:3px 0 0;font-size:12.5px;line-height:1.45;color:var(--dim)}
|
||||||
|
.faddr{margin-bottom:18px}
|
||||||
|
.moved{margin:2px 0 0;font-size:13px;color:var(--accent)}
|
||||||
|
/* On or off, drawn as iOS draws it, as the app is on a phone (#53). The box is the real control,
|
||||||
|
unseen over the track, so a click, a tap, the keyboard and a screen reader all reach it. */
|
||||||
|
.switch{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:38px;font-size:14px;font-weight:500;cursor:pointer}
|
||||||
|
/* The one switch the rest hang from. */
|
||||||
|
.switch.lead{font-weight:600}
|
||||||
|
.sw{position:relative;flex:none;width:42px;height:26px}
|
||||||
|
.sw input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer}
|
||||||
|
.sw i{position:absolute;inset:0;border-radius:999px;background:var(--raise);border:1px solid var(--line);pointer-events:none;transition:background-color .18s,border-color .18s}
|
||||||
|
/* White, as iOS has it, in every theme: in --ink, the dark themes' knob was a black hole in the track. */
|
||||||
|
.sw i::after{content:"";position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.35);transition:transform .18s}
|
||||||
|
.sw input:checked+i{background:var(--accent);border-color:var(--accent)}
|
||||||
|
.sw input:checked+i::after{transform:translateX(16px)}
|
||||||
|
.sw input:focus-visible+i{outline:2px solid var(--accent);outline-offset:2px}
|
||||||
|
/* The switch and what only matters while it downloads on its own, in one box; the rest folds away
|
||||||
|
when it is off. In --line, not --edge, so the box shows on a phone, which draws no outlines. The
|
||||||
|
rows in a grid of one row, 1fr open and 0fr shut, so the fold has a height to move. */
|
||||||
|
.fdl{border:1px solid var(--line);border-radius:12px;padding:4px 14px}
|
||||||
|
/* The same box round a part of a dialog that stands apart: API tokens in Settings. */
|
||||||
|
.field.boxed{border:1px solid var(--line);border-radius:12px;padding:12px 14px}
|
||||||
|
.fdeps{display:grid;grid-template-rows:1fr;transition:grid-template-rows .22s ease}
|
||||||
|
.fdeps.off{grid-template-rows:0fr}
|
||||||
|
.fdeps>div{min-height:0;overflow:hidden}
|
||||||
|
.fdepsin{padding:6px 2px 10px}
|
||||||
|
/* The app's own segmented control, with radio buttons in it, for the arrow keys. */
|
||||||
|
.seg{margin-bottom:12px}
|
||||||
|
.seg label{flex:1;position:relative;text-align:center;white-space:nowrap;padding:5px 10px;border-radius:7px;font-size:13px;color:var(--dim);cursor:pointer}
|
||||||
|
.seg input{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
|
||||||
|
.seg label:has(input:checked){background:var(--raise);color:var(--fg);font-weight:500}
|
||||||
|
.seg label:has(input:focus-visible){outline:2px solid var(--accent);outline-offset:-2px}
|
||||||
|
/* The feed's newest items, newest first, each as tall as it is long; lit, those that download on
|
||||||
|
their own. Dragged along, it sets how many. Only drawing: the number in the sentence below is
|
||||||
|
what a keyboard and a screen reader use. */
|
||||||
|
.epwin{display:flex;align-items:flex-end;gap:2px;height:38px;margin-bottom:8px;cursor:ew-resize;touch-action:pan-y;user-select:none;-webkit-user-select:none}
|
||||||
|
.epwin:empty{display:none}
|
||||||
|
.epwin i{flex:1 1 0;min-width:2px;max-width:10px;height:var(--h);border-radius:2px;background:var(--line);transition:background-color .2s calc(var(--n) * 12ms)}
|
||||||
|
.epwin i.on{background:var(--accent)}
|
||||||
|
.epwin i.nofile{height:4px}
|
||||||
|
.epwin small{flex:none;align-self:center;margin-left:8px;font-size:12px;color:var(--faint);font-variant-numeric:tabular-nums}
|
||||||
|
.epwin.all small{color:var(--accent)}
|
||||||
|
.wcap{margin:0 0 16px;font-size:13px;line-height:1.55;color:var(--dim)}
|
||||||
|
.wcap input[type=number]{display:inline-block;width:4.2em;margin:0 2px;padding:1px 4px;font-size:13px;text-align:center;font-variant-numeric:tabular-nums;color:var(--fg)}
|
||||||
|
.ffield{display:grid;gap:6px;margin-bottom:14px}
|
||||||
|
/* DeadAir's pause: four stops on one scale, a dot at each so it reads as four places to stop. */
|
||||||
|
.dpause{display:grid;gap:6px;margin-top:6px}
|
||||||
|
.dphead{display:flex;justify-content:space-between;align-items:baseline}
|
||||||
|
.dphead output{font-variant-numeric:tabular-nums;font-weight:600}
|
||||||
|
.dpslide{--t:22px;position:relative;height:var(--t)}
|
||||||
|
.dpslide::before,.dpslide::after{content:'';position:absolute;top:50%;pointer-events:none}
|
||||||
|
.dpslide::before{left:0;right:0;height:6px;margin-top:-3px;border-radius:3px;background:var(--raise)}
|
||||||
|
.dpslide::after{left:calc(var(--t)/2 - 3px);right:calc(var(--t)/2 - 3px);height:6px;margin-top:-3px;
|
||||||
|
background:radial-gradient(circle,var(--dim) 2px,transparent 2.5px) 0 0/6px 6px no-repeat,
|
||||||
|
radial-gradient(circle,var(--dim) 2px,transparent 2.5px) 33.333% 0/6px 6px no-repeat,
|
||||||
|
radial-gradient(circle,var(--dim) 2px,transparent 2.5px) 66.667% 0/6px 6px no-repeat,
|
||||||
|
radial-gradient(circle,var(--dim) 2px,transparent 2.5px) 100% 0/6px 6px no-repeat}
|
||||||
|
.dpslide input[type=range]{position:relative;z-index:1;width:100%;height:var(--t);margin:0;background:transparent}
|
||||||
|
.dpslide input[type=range]::-webkit-slider-thumb{width:var(--t);height:var(--t);box-shadow:0 0 0 3px var(--panel),0 1px 4px rgba(0,0,0,.3)}
|
||||||
|
.dpslide input[type=range]::-moz-range-thumb{width:var(--t);height:var(--t);box-shadow:0 0 0 3px var(--panel),0 1px 4px rgba(0,0,0,.3)}
|
||||||
|
.dpslide input[type=range]:focus-visible{outline:2px solid var(--accent);outline-offset:4px;border-radius:var(--t)}
|
||||||
|
.dpends{display:flex;justify-content:space-between;font-size:12.5px;color:var(--dim)}
|
||||||
|
.dpause:has(input:disabled){opacity:.45}
|
||||||
|
.dpslide input[type=range]:disabled{cursor:default}
|
||||||
|
.fdl+.ffield{margin-top:18px}
|
||||||
|
/* DeadAir shares the box but not the fold: it applies whether or not this show downloads on its own. */
|
||||||
|
.fdead{border-top:1px solid var(--line);padding-top:10px;margin-top:4px}
|
||||||
|
.flabel{font-size:14px;font-weight:500}
|
||||||
|
.cap{margin:0;font-size:12.5px;line-height:1.45;color:var(--dim)}
|
||||||
|
/* Words and phrases as chips: in one comma-separated box, one ran into the next. */
|
||||||
|
.chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center;min-height:38px;padding:5px;border:1px solid var(--line);border-radius:9px;background:var(--bg);cursor:text}
|
||||||
|
.chips:focus-within{border-color:var(--accent)}
|
||||||
|
.chip{display:inline-flex;align-items:center;gap:2px;max-width:100%;padding:3px 3px 3px 10px;border-radius:999px;background:var(--panel2);color:var(--fg);font-size:13px;line-height:1.3;overflow-wrap:anywhere}
|
||||||
|
.chipx{display:grid;place-items:center;flex:none;width:20px;height:20px;padding:0;border-radius:50%;color:var(--dim)}
|
||||||
|
.chipx:hover{background:var(--raise);color:var(--fg)}
|
||||||
|
.chipx .i{width:9px;height:9px}
|
||||||
|
.chips .chipin{flex:1 0 8ch;width:auto;min-width:0;padding:4px 6px;border:0;background:none}
|
||||||
#toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60}
|
#toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60}
|
||||||
.toast{
|
.toast{
|
||||||
background:var(--raise);border:1px solid var(--line);border-radius:9px;
|
background:var(--raise);border:1px solid var(--line);border-radius:9px;
|
||||||
@@ -967,8 +1190,10 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
|
|
||||||
@media (max-width:820px){
|
@media (max-width:820px){
|
||||||
#shell{grid-template-columns:1fr}
|
#shell{grid-template-columns:1fr}
|
||||||
#sidebar{position:fixed;inset:0 auto 0 0;padding-top:var(--safe-t);width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
|
/* The shadow only while open: off the screen, the drawer's fell across the page's left edge,
|
||||||
#sidebar.open{transform:none}
|
the top bar's included (#151). */
|
||||||
|
#sidebar{position:fixed;inset:0 auto 0 0;padding-top:var(--safe-t);width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s,box-shadow .2s}
|
||||||
|
#sidebar.open{transform:none;box-shadow:var(--shadow)}
|
||||||
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
|
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
|
||||||
#player{
|
#player{
|
||||||
position:relative;z-index:39;gap:8px;
|
position:relative;z-index:39;gap:8px;
|
||||||
@@ -978,6 +1203,8 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
|
|
||||||
/* The feed list is reachable whether or not anything is playing. */
|
/* The feed list is reachable whether or not anything is playing. */
|
||||||
#burger{display:grid}
|
#burger{display:grid}
|
||||||
|
/* No logo on a phone: the bar has no room to spare for it, and no hover to show its version. */
|
||||||
|
#applogo{display:none}
|
||||||
#topbar{
|
#topbar{
|
||||||
gap:6px;padding-top:calc(6px + var(--safe-t));padding-bottom:6px;
|
gap:6px;padding-top:calc(6px + var(--safe-t));padding-bottom:6px;
|
||||||
padding-left:calc(8px + var(--safe-l));padding-right:calc(8px + var(--safe-r));
|
padding-left:calc(8px + var(--safe-l));padding-right:calc(8px + var(--safe-r));
|
||||||
@@ -1003,8 +1230,8 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
#dpeek>div{width:calc(100% - 16px);height:100%;overflow:hidden;padding:12px 16px 90px;padding-top:calc(12px + var(--safe-t));background:var(--panel)}
|
#dpeek>div{width:calc(100% - 16px);height:100%;overflow:hidden;padding:12px 16px 90px;padding-top:calc(12px + var(--safe-t));background:var(--panel)}
|
||||||
#dpeek.next>div{margin-left:16px}
|
#dpeek.next>div{margin-left:16px}
|
||||||
#dpeek .encbox{margin:0 0 10px}
|
#dpeek .encbox{margin:0 0 10px}
|
||||||
#dpeek.end{display:flex;align-items:center;padding-left:28px;color:var(--faint);font-size:13px}
|
/* Past the last item, the accent and no words: the colour says it is the end. */
|
||||||
#dpeek.end p{max-width:4.5em;margin:0}
|
#dpeek.end{background:var(--accent)}
|
||||||
/* Going back to the list from the first item: the list waits under a dimmer that lifts as the
|
/* Going back to the list from the first item: the list waits under a dimmer that lifts as the
|
||||||
reader, shadowed along its edge, is drawn off it. */
|
reader, shadowed along its edge, is drawn off it. */
|
||||||
#dpeek.dim{width:100%;z-index:37;background:#000}
|
#dpeek.dim{width:100%;z-index:37;background:#000}
|
||||||
@@ -1028,9 +1255,22 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
#pnow .txt small,#pright #vol{display:none}
|
#pnow .txt small,#pright #vol{display:none}
|
||||||
.wrap.plain{padding:14px}
|
.wrap.plain{padding:14px}
|
||||||
.card{padding:16px}
|
.card{padding:16px}
|
||||||
/* The chips get a line of their own and scroll sideways rather than wrap, so they never push
|
/* The subcategories get a line of their own and scroll sideways rather than wrap, so they
|
||||||
the grid down. */
|
never push the grid down. */
|
||||||
.chips{flex:1 1 100%;flex-wrap:nowrap;overflow-x:auto}
|
.dtitle h2{font-size:25px}
|
||||||
|
.dsec{margin-top:22px}
|
||||||
|
.dsech h3{font-size:17px}
|
||||||
|
.chart{grid-template-columns:minmax(0,1fr);grid-template-rows:none;grid-auto-flow:row}
|
||||||
|
.cats{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
|
||||||
|
.cat{height:100px;padding:11px 12px}
|
||||||
|
.cat b{font-size:15px}
|
||||||
|
.cat .fan{right:6px;bottom:-16px;transform:scale(.7);transform-origin:bottom right}
|
||||||
|
.pills{flex:1 1 100%;flex-wrap:nowrap;overflow-x:auto}
|
||||||
|
.dtools{flex-wrap:wrap}
|
||||||
|
.show{flex-direction:column;align-items:flex-start;gap:14px}
|
||||||
|
.show>.art{width:150px;height:150px;font-size:40px}
|
||||||
|
.show h2{font-size:24px}
|
||||||
|
#dirsort{margin-left:auto}
|
||||||
.tiles{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:14px 10px}
|
.tiles{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:14px 10px}
|
||||||
|
|
||||||
/* A log line has no room for four columns: keep time and message, wrap as prose. */
|
/* A log line has no room for four columns: keep time and message, wrap as prose. */
|
||||||
@@ -1086,6 +1326,12 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
.fhead .sub{font-size:15px}
|
.fhead .sub{font-size:15px}
|
||||||
.acts{gap:8px}
|
.acts{gap:8px}
|
||||||
.acts .btn{min-width:44px;height:44px;padding:0 13px;justify-content:center;font-size:15px}
|
.acts .btn{min-width:44px;height:44px;padding:0 13px;justify-content:center;font-size:15px}
|
||||||
|
/* Seven to a row with no room to spare at 390px: below that an icon button narrows rather than
|
||||||
|
the row breaking, which left Unsubscribe alone on a second row (#146). */
|
||||||
|
.acts .btn.ico{flex:0 1 44px;min-width:0;padding:0}
|
||||||
|
/* A Directory feed's few buttons have room, and nothing sizes their row: allowed to shrink,
|
||||||
|
they went to 18px (#212). */
|
||||||
|
.show .acts .btn.ico{flex:0 0 44px}
|
||||||
|
|
||||||
/* Tabs as a segmented control: equal segments on one track, the chosen one lifted. */
|
/* Tabs as a segmented control: equal segments on one track, the chosen one lifted. */
|
||||||
.tabs{flex:1 1 100%;border-radius:10px;padding:2px}
|
.tabs{flex:1 1 100%;border-radius:10px;padding:2px}
|
||||||
@@ -1120,6 +1366,27 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
.card h3{font-size:22px}
|
.card h3{font-size:22px}
|
||||||
.field label,.field .hint{font-size:13px}
|
.field label,.field .hint{font-size:13px}
|
||||||
.check{font-size:17px}
|
.check{font-size:17px}
|
||||||
|
/* A feed's settings, at a phone's sizes. */
|
||||||
|
.card.fset{padding:0}
|
||||||
|
.fsetbody{padding:20px 16px 24px}
|
||||||
|
.card.fset .fsetfoot{padding:12px 16px calc(12px + var(--safe-b))}
|
||||||
|
.fsethead .art{width:64px;height:64px;border-radius:14px}
|
||||||
|
.fsethead p,.wcap,.cap{font-size:14px}
|
||||||
|
.switch,.flabel{font-size:17px}
|
||||||
|
.switch{min-height:44px}
|
||||||
|
.sw{width:51px;height:31px}
|
||||||
|
.sw i::after{width:25px;height:25px}
|
||||||
|
.sw input:checked+i::after{transform:translateX(20px)}
|
||||||
|
.seg label{padding:7px 4px;border-radius:8px;font-size:14px}
|
||||||
|
.seg label:has(input:checked){box-shadow:0 1px 3px rgba(0,0,0,.22)}
|
||||||
|
.dpslide{--t:28px}
|
||||||
|
.dpends{font-size:14px}
|
||||||
|
/* 16px or more, or Safari zooms the page in on it. */
|
||||||
|
.wcap input[type=number]{font-size:16px;padding:3px 4px}
|
||||||
|
.chips{background:var(--panel2);border-color:var(--edge);border-radius:10px}
|
||||||
|
.chip{background:var(--panel);font-size:15px;padding:5px 4px 5px 12px}
|
||||||
|
.chipx{width:26px;height:26px}
|
||||||
|
.fdl,.field.boxed{border-radius:14px}
|
||||||
.toast{border-radius:14px}
|
.toast{border-radius:14px}
|
||||||
}
|
}
|
||||||
@keyframes sheet{from{transform:translateY(100%)}}
|
@keyframes sheet{from{transform:translateY(100%)}}
|
||||||
@@ -1133,6 +1400,8 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
:root[data-theme="classic"] #topbar,
|
:root[data-theme="classic"] #topbar,
|
||||||
:root[data-theme="classic"] #status,
|
:root[data-theme="classic"] #status,
|
||||||
:root[data-theme="classic"] #player{background:linear-gradient(#ececec,#c9c9c9);border-color:#8e8e8e;color:#1a1a1a}
|
:root[data-theme="classic"] #player{background:linear-gradient(#ececec,#c9c9c9);border-color:#8e8e8e;color:#1a1a1a}
|
||||||
|
/* The colour iOS takes for above the bar, where it reads no gradient: the top of the metal. */
|
||||||
|
:root[data-theme="classic"] #topbar{background-color:#ececec}
|
||||||
:root[data-theme="classic"] #status{text-align:center}
|
:root[data-theme="classic"] #status{text-align:center}
|
||||||
:root[data-theme="classic"] #sidebar{background:#e7ebf1;border-right-color:#a3a3a3}
|
:root[data-theme="classic"] #sidebar{background:#e7ebf1;border-right-color:#a3a3a3}
|
||||||
:root[data-theme="classic"] #files{background:#f4f4f4}
|
:root[data-theme="classic"] #files{background:#f4f4f4}
|
||||||
@@ -1153,6 +1422,11 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
:root[data-theme="classic"] .feed.sel .txt small,
|
:root[data-theme="classic"] .feed.sel .txt small,
|
||||||
:root[data-theme="classic"] .place.sel .ico{color:#e6eeff}
|
:root[data-theme="classic"] .place.sel .ico{color:#e6eeff}
|
||||||
:root[data-theme="classic"] #epSearch{border-radius:14px}
|
:root[data-theme="classic"] #epSearch{border-radius:14px}
|
||||||
|
/* The 2004 app had checkboxes, not switches. */
|
||||||
|
:root[data-theme="classic"] .sw{width:auto;height:auto}
|
||||||
|
:root[data-theme="classic"] .sw input{position:static;width:14px;height:14px;opacity:1}
|
||||||
|
:root[data-theme="classic"] .sw i{display:none}
|
||||||
|
:root[data-theme="classic"] .seg label:has(input:checked){background:linear-gradient(#86b0ec,#3a73cf);color:#fff}
|
||||||
:root[data-theme="classic"] .badge{background:#c9302c;color:#fff}
|
:root[data-theme="classic"] .badge{background:#c9302c;color:#fff}
|
||||||
:root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff}
|
:root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff}
|
||||||
:root[data-theme="classic"] .ephead,
|
:root[data-theme="classic"] .ephead,
|
||||||
@@ -1229,7 +1503,7 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
:root[data-theme="glass"] .toast.bad{border-color:var(--bad)}
|
:root[data-theme="glass"] .toast.bad{border-color:var(--bad)}
|
||||||
/* A narrow screen slides the feed list over the page, so it keeps its shadow. */
|
/* A narrow screen slides the feed list over the page, so it keeps its shadow. */
|
||||||
@media (max-width:820px){
|
@media (max-width:820px){
|
||||||
:root[data-theme="glass"] #sidebar{box-shadow:inset -1px 0 0 var(--glass-hi),var(--shadow)}
|
:root[data-theme="glass"] #sidebar.open{box-shadow:inset -1px 0 0 var(--glass-hi),var(--shadow)}
|
||||||
}
|
}
|
||||||
/* The card is the glass; a lighter scrim leaves something behind it to see through. */
|
/* The card is the glass; a lighter scrim leaves something behind it to see through. */
|
||||||
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.3)}
|
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.3)}
|
||||||
@@ -1248,12 +1522,17 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
:root[data-theme="glass"] .sidefoot button{border-radius:10px}
|
:root[data-theme="glass"] .sidefoot button{border-radius:10px}
|
||||||
}
|
}
|
||||||
:root[data-theme="glass"] .btn.primary{box-shadow:inset 0 1px 0 rgba(255,255,255,.35)}
|
:root[data-theme="glass"] .btn.primary{box-shadow:inset 0 1px 0 rgba(255,255,255,.35)}
|
||||||
|
:root[data-theme="glass"] .card.fset .fsetfoot{border-top-color:var(--glass-edge);background:color-mix(in srgb,var(--panel) 60%,transparent)}
|
||||||
/* On a phone the rim all round a small capsule or bar read as the outline #53 took away, so the
|
/* On a phone the rim all round a small capsule or bar read as the outline #53 took away, so the
|
||||||
light only catches the top edge there, and the bars at the top and bottom have none. */
|
light only catches the top edge there, and the bars at the top and bottom have none. */
|
||||||
@media (max-width:820px){
|
@media (max-width:820px){
|
||||||
:root[data-theme="glass"]{--glass-rim:inset 0 1px 0 var(--glass-lo)}
|
:root[data-theme="glass"]{--glass-rim:inset 0 1px 0 var(--glass-lo)}
|
||||||
:root[data-theme="glass"] #topbar,
|
:root[data-theme="glass"] #topbar,
|
||||||
:root[data-theme="glass"] #status{box-shadow:none}
|
:root[data-theme="glass"] #status{box-shadow:none}
|
||||||
|
/* Solid, in the colour the glass showed on average. iOS paints the strip above it, behind the
|
||||||
|
status bar and the Dynamic Island, in one flat colour, and over a see-through bar that met it
|
||||||
|
at a seam: near-black above a blue bar (#147). */
|
||||||
|
:root[data-theme="glass"] #topbar{background:var(--glass-bar);-webkit-backdrop-filter:none;backdrop-filter:none}
|
||||||
}
|
}
|
||||||
/* Someone who has asked the system for less transparency, or more contrast, gets solid panels. */
|
/* Someone who has asked the system for less transparency, or more contrast, gets solid panels. */
|
||||||
@media (prefers-reduced-transparency:reduce),(prefers-contrast:more){
|
@media (prefers-reduced-transparency:reduce),(prefers-contrast:more){
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
6
web/boot.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Runs in <head>, before the first frame (#203): this browser's theme, from its cookie, and
|
||||||
|
// whether the last visit here was an admin's, so the link to Admin is there from the start. The
|
||||||
|
// server wrote both into the page while it served it; the page is a static file now.
|
||||||
|
(()=>{const r=document.documentElement,c=/(?:^|; )ipx_theme=([a-z]{1,20})\.(light|dark|auto)(?:;|$)/.exec(document.cookie);
|
||||||
|
if(c){r.dataset.theme=c[1];r.dataset.choice=c[2];if(c[2]!=='auto')r.dataset.mode=c[2];}
|
||||||
|
try{if(localStorage.getItem('ipx.admin')==='1')r.dataset.admin='';}catch{}})();
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
// Builds the pages ipx serves: each page's TypeScript from web/src, types stripped and minified
|
// Builds the frontend (#203): each page's TypeScript from web/src, types stripped and minified
|
||||||
// by swc into a script of its own (app.js, login.js), and the page minified, its
|
// by swc into a script of its own (app.js, login.js), the page minified, its <script data-src>
|
||||||
// <script data-src> pointing at that script. build.rs runs it into OUT_DIR, where web.rs
|
// pointing at that script, and the icons, logos and typeface beside them. The result, web/dist,
|
||||||
// include_str!s the results, so the binary still carries everything and nothing is served
|
// is a static site: Caddy serves it (web/Caddyfile, Dockerfile.web) and passes the API to ipx.
|
||||||
// from disk.
|
|
||||||
//
|
//
|
||||||
// The page names its script with a hash of the script's contents, /app.js?v=<hash>, and the
|
// The page names its script with a hash of the script's contents, /app.js?v=<hash>, and the
|
||||||
// server lets a browser keep that for a year without asking again. A changed script is a new
|
// server lets a browser keep that for a year without asking again. A changed script is a new
|
||||||
// URL, and the page, which the browser checks on every visit, is what carries it.
|
// URL, and the page, which the browser checks on every visit, is what carries it. Caddy's file
|
||||||
|
// server ignores the query.
|
||||||
//
|
//
|
||||||
// node web/build.mjs [out-dir] default out-dir: web/dist
|
// node web/build.mjs [out-dir] default out-dir: web/dist
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
@@ -17,6 +17,8 @@ import swc from '@swc/core';
|
|||||||
import html from '@swc/html';
|
import html from '@swc/html';
|
||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
// The page's own version (package.json), given to its script in place of __WEB_VERSION__.
|
||||||
|
const WEB_VERSION = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8')).version;
|
||||||
|
|
||||||
// The files are one script, concatenated in this order, not modules: they share one top-level
|
// 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.
|
// scope, as the single inline script did, and code that runs at load needs what came before it.
|
||||||
@@ -30,6 +32,11 @@ const STYLE = 'app.css';
|
|||||||
|
|
||||||
const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
|
const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
|
||||||
|
|
||||||
|
// hls.js, for browsers that cannot play DeadAir's playlists themselves (#181). Served as it comes,
|
||||||
|
// by a name with its hash in it, which the page's script is given in place of __HLS_JS__.
|
||||||
|
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)}`;
|
||||||
|
|
||||||
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
|
/// 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.
|
/// one; the doctype only keeps it from complaining that a fragment has none.
|
||||||
export function buildStyle({ minify = true } = {}) {
|
export function buildStyle({ minify = true } = {}) {
|
||||||
@@ -51,7 +58,9 @@ export function buildStyle({ minify = true } = {}) {
|
|||||||
/// The page and its script, built: { html, js, script }, where script is the file's name.
|
/// The page and its script, built: { html, js, script }, where script is the file's name.
|
||||||
export function buildPage(name, { minify = true } = {}) {
|
export function buildPage(name, { minify = true } = {}) {
|
||||||
const { script, src: files } = PAGES[name];
|
const { script, src: files } = PAGES[name];
|
||||||
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n');
|
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n')
|
||||||
|
.replaceAll('__HLS_JS__', HLS_URL)
|
||||||
|
.replaceAll('__WEB_VERSION__', WEB_VERSION);
|
||||||
const js = swc.transformSync(src, {
|
const js = swc.transformSync(src, {
|
||||||
filename: name + '.ts',
|
filename: name + '.ts',
|
||||||
jsc: {
|
jsc: {
|
||||||
@@ -75,7 +84,7 @@ export function buildPage(name, { minify = true } = {}) {
|
|||||||
// The icons are named by their contents too. They are kept a day under a fixed name, and a new
|
// The icons are named by their contents too. They are kept a day under a fixed name, and a new
|
||||||
// logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the
|
// logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the
|
||||||
// query; /favicon.ico, which a browser asks for on its own, cannot carry one.
|
// query; /favicon.ico, which a browser asks for on its own, cannot carry one.
|
||||||
out = out.replace(/(href|src)="\/(favicon\.png|apple-touch-icon\.png|logo\.svg)"/g,
|
out = out.replace(/(href|src|srcset|data-light|data-dark)="\/(favicon\.png|favicon-dark\.png|apple-touch-icon\.png|logo\.svg|logo-dark\.svg|boot\.js)"/g,
|
||||||
(_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`);
|
(_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`);
|
||||||
if (!minify) return { html: out, js, script };
|
if (!minify) return { html: out, js, script };
|
||||||
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
|
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
|
||||||
@@ -88,6 +97,17 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|||||||
const out = process.argv[2] || path.join(here, 'dist');
|
const out = process.argv[2] || path.join(here, 'dist');
|
||||||
fs.mkdirSync(out, { recursive: true });
|
fs.mkdirSync(out, { recursive: true });
|
||||||
fs.writeFileSync(path.join(out, STYLE), buildStyle());
|
fs.writeFileSync(path.join(out, STYLE), buildStyle());
|
||||||
|
fs.writeFileSync(path.join(out, 'hls.js'), HLS);
|
||||||
|
// Served as they are, under the names the pages and the stylesheet use. /favicon.ico is the
|
||||||
|
// PNG, for a browser that asks for that on its own; iOS asks for the precomposed touch icon
|
||||||
|
// first when the site is added to a home screen (#109). /icon.png is the 2004 icon, which
|
||||||
|
// nothing here shows any more but the classic theme.
|
||||||
|
for (const [from, to] of [
|
||||||
|
['logo.svg', 'logo.svg'], ['logo-dark.svg', 'logo-dark.svg'],
|
||||||
|
['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 name of Object.keys(PAGES)) {
|
for (const name of Object.keys(PAGES)) {
|
||||||
const { html, js, script } = buildPage(name);
|
const { html, js, script } = buildPage(name);
|
||||||
fs.writeFileSync(path.join(out, name), html);
|
fs.writeFileSync(path.join(out, name), html);
|
||||||
|
|||||||
BIN
web/favicon-dark.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
web/favicon.png
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
@@ -4,16 +4,19 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="color-scheme" content="dark light">
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<meta name="theme-color" content="#0a1726">
|
||||||
<title>iPX</title>
|
<title>iPX</title>
|
||||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" data-src="app.css">
|
<link rel="stylesheet" data-src="app.css">
|
||||||
|
<script src="/boot.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header id="topbar">
|
<header id="topbar">
|
||||||
<button class="iconbtn" id="burger" title="Feeds" aria-label="Feeds" data-icon="menu"></button>
|
<button class="iconbtn" id="burger" title="Feeds" aria-label="Feeds" data-icon="menu"></button>
|
||||||
<!-- One group per thing acted on, in the order the panes read: feeds, then the selected item.
|
<!-- One group per thing acted on, in the order the panes read: feeds, then the selected item.
|
||||||
A phone hides the item group, which it has no table for. -->
|
A phone hides the item group, which it has no table for. -->
|
||||||
|
<span id="applogo" title="iPX"><img class="logo onlight" src="/logo.svg" alt="iPX"><img class="logo ondark" src="/logo-dark.svg" alt="iPX"><img class="logo classic" src="/icon.png" alt="iPodderX"></span>
|
||||||
<div class="tgroup">
|
<div class="tgroup">
|
||||||
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
|
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
|
||||||
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="circleMinus" disabled></button>
|
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="circleMinus" disabled></button>
|
||||||
@@ -33,9 +36,6 @@
|
|||||||
</header>
|
</header>
|
||||||
<div id="shell">
|
<div id="shell">
|
||||||
<aside id="sidebar">
|
<aside id="sidebar">
|
||||||
<div class="brand">
|
|
||||||
<img class="logo" src="/logo.svg" alt="iPX"><h1>iPX</h1>
|
|
||||||
</div>
|
|
||||||
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
|
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
|
||||||
<div id="feedlist"></div>
|
<div id="feedlist"></div>
|
||||||
<div class="sidefoot">
|
<div class="sidefoot">
|
||||||
@@ -67,6 +67,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="pright">
|
<div id="pright">
|
||||||
|
<button class="iconbtn" id="pdead" aria-pressed="false" aria-label="DeadAir" hidden></button>
|
||||||
<select id="rate" title="Speed">
|
<select id="rate" title="Speed">
|
||||||
<option value="0.8">0.8×</option><option value="1" selected>1×</option>
|
<option value="0.8">0.8×</option><option value="1" selected>1×</option>
|
||||||
<option value="1.25">1.25×</option><option value="1.5">1.5×</option>
|
<option value="1.25">1.25×</option><option value="1.5">1.5×</option>
|
||||||
|
|||||||
@@ -4,46 +4,48 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="dark light">
|
<meta name="color-scheme" content="dark light">
|
||||||
<title>Sign in — iPX</title>
|
<title>Sign in — iPX</title>
|
||||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png" media="(prefers-color-scheme:light)">
|
||||||
|
<link rel="icon" type="image/png" sizes="128x128" href="/favicon-dark.png" media="(prefers-color-scheme:dark)">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<style>
|
<style>
|
||||||
/* Inter, from ipx itself; see the same rule in index.html. */
|
/* Inter, from ipx itself; see the same rule in index.html. */
|
||||||
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
|
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
|
||||||
:root {
|
:root {
|
||||||
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */
|
--bg:#0a1726; /* the dark logo's navy (#0c2238), taken down */
|
||||||
--panel:#151c27;
|
--panel:#0f1f31;
|
||||||
--panel2:#1c2431;
|
--panel2:#15283d;
|
||||||
--raise:#25303f;
|
--raise:#1d3450;
|
||||||
--line:#2c3849;
|
--line:#264060;
|
||||||
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
--fg:#f3f7fb;
|
||||||
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
--dim:#a3b6ca;
|
||||||
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
|
--faint:#8a9fb5;
|
||||||
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
--accent:#8fc2ea; /* #8FC2EA the dark logo's scale */
|
||||||
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
--accent2:#ff6a1a; /* #FF6A1A the dark logo's needle */
|
||||||
--ink:#0e131b; /* text on an accent fill */
|
--ink:#0a1726; /* text on an accent fill */
|
||||||
--good:#6fbf8b;
|
--good:#6fbf8b;
|
||||||
--warn:#f49e2c; /* the amber doubles as the pending colour */
|
--warn:#f5a524; /* amber, apart from the needle's orange, for pending */
|
||||||
--bad:#e2705f;
|
--bad:#ff6b7a; /* pushed towards pink, apart from the needle */
|
||||||
--shadow:0 8px 28px rgba(6,10,16,.55);
|
--shadow:0 8px 28px rgba(4,10,20,.55);
|
||||||
--r:10px;
|
--r:10px;
|
||||||
}
|
}
|
||||||
/* Signed out, there is no account to take a theme from, so this follows the system. */
|
/* Modern, as app.css has it. Signed out, there is no account to take a theme from, so this follows
|
||||||
|
the system. */
|
||||||
@media (prefers-color-scheme:light){:root {
|
@media (prefers-color-scheme:light){:root {
|
||||||
--bg:#f2f4f7;
|
--bg:#eef5fb; /* the light logo's sky (#DFF0FB), paler */
|
||||||
--panel:#ffffff; /* #FFFFFF device body */
|
--panel:#ffffff;
|
||||||
--panel2:#e9edf3;
|
--panel2:#e4eff9;
|
||||||
--raise:#dde3ec;
|
--raise:#d5e6f5;
|
||||||
--line:#d6d6d6; /* #D6D6D6 device edge */
|
--line:#c6d9ea;
|
||||||
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
--fg:#10233a;
|
||||||
--dim:#606060; /* #606060 */
|
--dim:#46596e;
|
||||||
--faint:#6b6b6b;
|
--faint:#56697e;
|
||||||
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
--accent:#2f6aa0; /* #2F6AA0 the logo's scale */
|
||||||
--accent2:#985e0a;
|
--accent2:#c43e00; /* the needle (#FF5500), taken down until white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#2f7d4f;
|
--good:#2f7d4f;
|
||||||
--warn:#985e0a;
|
--warn:#985e0a; /* amber, apart from the needle's orange; lighter failed AA as text */
|
||||||
--bad:#b3402f;
|
--bad:#b3263a; /* pushed towards crimson, apart from the needle */
|
||||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
--shadow:0 8px 28px rgba(47,106,160,.14);
|
||||||
}}
|
}}
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
html,body{height:100%}
|
html,body{height:100%}
|
||||||
@@ -74,7 +76,7 @@ button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<form id="f">
|
<form id="f">
|
||||||
<div class="brand"><img src="/logo.svg" alt=""><h1>iPX</h1></div>
|
<div class="brand"><picture><source media="(prefers-color-scheme:light)" srcset="/logo.svg"><img src="/logo-dark.svg" alt=""></picture><h1>iPX</h1></div>
|
||||||
<label for="name">Name</label>
|
<label for="name">Name</label>
|
||||||
<input id="name" name="name" autocomplete="username" autofocus required>
|
<input id="name" name="name" autocomplete="username" autofocus required>
|
||||||
<label for="pw">Password</label>
|
<label for="pw">Password</label>
|
||||||
|
|||||||
@@ -15,5 +15,5 @@
|
|||||||
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
|
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
|
||||||
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
|
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
|
||||||
</g>
|
</g>
|
||||||
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#ff9f2e"/></g>
|
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#ff6a1a"/></g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1003 B After Width: | Height: | Size: 1003 B |
@@ -18,5 +18,5 @@
|
|||||||
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
|
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
|
||||||
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
|
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
|
||||||
</g>
|
</g>
|
||||||
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#f7931e"/></g>
|
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#ff5500"/></g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
119
web/src/admin.ts
@@ -7,7 +7,7 @@
|
|||||||
let me: {name: string} | null = null;
|
let me: {name: string} | null = null;
|
||||||
api('/api/me').then(u => { me = u; }).catch(() => {});
|
api('/api/me').then(u => { me = u; }).catch(() => {});
|
||||||
|
|
||||||
const SECTIONS: Record<string, () => void> = {server: drawServer, accounts: drawAccounts, log: drawLogView};
|
const SECTIONS: Record<string, () => void> = {server: drawServer, accounts: drawAccounts, directory: drawDirectorySettings, log: drawLogView};
|
||||||
|
|
||||||
function showSection(){
|
function showSection(){
|
||||||
const t = SECTIONS[location.hash.slice(1)] ? location.hash.slice(1) : 'server';
|
const t = SECTIONS[location.hash.slice(1)] ? location.hash.slice(1) : 'server';
|
||||||
@@ -34,11 +34,11 @@ async function drawServer(){
|
|||||||
</div>
|
</div>
|
||||||
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
|
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
|
||||||
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
|
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
|
||||||
<div class="field"><label>Max new downloads per scan, per feed</label>
|
<div class="field"><label>Newest episodes to download, per feed</label>
|
||||||
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
|
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
|
||||||
<span class="hint">Applies to any feed that does not set its own — including every feed
|
<span class="hint">Applies to any feed that does not set its own — including every feed
|
||||||
inside an OPML subscription. <b>0 means unlimited</b>, which will pull a whole back
|
inside an OPML subscription. Older episodes stay listed to download by hand. <b>0 means
|
||||||
catalogue the first time a feed is scanned.</span></div>
|
every episode</b>, the whole back catalogue.</span></div>
|
||||||
<div class="field"><label>Download these media types automatically</label>
|
<div class="field"><label>Download these media types automatically</label>
|
||||||
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video">
|
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video">
|
||||||
<span class="hint">Anything else is still listed and can be downloaded by hand — blog feeds
|
<span class="hint">Anything else is still listed and can be downloaded by hand — blog feeds
|
||||||
@@ -49,6 +49,10 @@ async function drawServer(){
|
|||||||
items are never touched.</span></div>
|
items are never touched.</span></div>
|
||||||
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
|
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
|
||||||
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
|
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
|
||||||
|
<div class="field"><label>Artwork kept (MB, 0 = none)</label>
|
||||||
|
<input type="number" id="gart" min="0" value="${g.art_cache_mb}">
|
||||||
|
<span class="hint">Show and episode artwork is kept here once shown, so it loads from iPX
|
||||||
|
instead of each publisher. Over this, what has gone longest unshown is dropped first.</span></div>
|
||||||
<div class="field"><label>Download folder</label>
|
<div class="field"><label>Download folder</label>
|
||||||
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
<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>`;
|
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
|
||||||
@@ -59,12 +63,117 @@ async function drawServer(){
|
|||||||
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
|
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
|
||||||
media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean),
|
media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean),
|
||||||
max_total_gb: Number($('#gquota').value) || 0,
|
max_total_gb: Number($('#gquota').value) || 0,
|
||||||
max_age_days: Number($('#gage').value) || 0})});
|
max_age_days: Number($('#gage').value) || 0,
|
||||||
|
art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})});
|
||||||
toast('Settings saved');
|
toast('Settings saved');
|
||||||
}catch(e){ toast(e.message, true); }
|
}catch(e){ toast(e.message, true); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- directory ---------------- */
|
||||||
|
// The directory service the Directory shows (#205): where it is, this server's token for it, and
|
||||||
|
// whether to share. The token is sent, never shown: the server says only whether one is set.
|
||||||
|
const ACCESS: Record<string, string> = {ro: 'browse', rw: 'browse and share', rwa: 'browse, share and administer the directory'};
|
||||||
|
|
||||||
|
async function drawDirectorySettings(){
|
||||||
|
const box = $('#directory');
|
||||||
|
const g = await api('/api/settings');
|
||||||
|
box.innerHTML = `<h2>Directory</h2>
|
||||||
|
<p class="hint">The Directory shows podcasts from a directory service that iPX servers share.
|
||||||
|
Without one, people add feeds by their address.</p>
|
||||||
|
<div class="field"><label for="durl">Directory address</label>
|
||||||
|
<input type="text" inputmode="url" id="durl" value="${esc(g.directory_url)}" placeholder="https://directory.example.net" spellcheck="false"></div>
|
||||||
|
<div class="field"><label for="dtoken">Access token</label>
|
||||||
|
<div class="inline"><input type="password" id="dtoken" autocomplete="off" spellcheck="false"
|
||||||
|
placeholder="${g.directory_token_set ? 'Set. Paste a new one to replace it' : 'Paste the token the directory gave this server'}">
|
||||||
|
${g.directory_token_set ? `<button type="button" class="btn" id="dclear">Clear</button>` : ''}</div>
|
||||||
|
<span class="hint">Whoever runs the directory makes one for each server, and can revoke it.</span></div>
|
||||||
|
<div class="field"><label class="check"><input type="checkbox" id="dshare"${g.directory_share ? ' checked' : ''}> Share this server's public feeds</label>
|
||||||
|
<span class="hint">Sends the directory the feeds people here subscribe to, and how many of them do, every
|
||||||
|
half hour. Never a private feed: one marked private, one with a login, or one whose address carries a key.</span></div>
|
||||||
|
<div class="field"><label>Status</label><p class="hint" id="dstatus">Asking the directory…</p></div>
|
||||||
|
<div class="cardacts"><button class="btn primary" id="dsave">${ICON.check} Save</button></div>
|
||||||
|
<div id="dadmin"></div>`;
|
||||||
|
const save = async (patch: Record<string, unknown>, said: string) => {
|
||||||
|
try{
|
||||||
|
await api('/api/settings', {method: 'PATCH', body: JSON.stringify(patch)});
|
||||||
|
toast(said);
|
||||||
|
drawDirectorySettings();
|
||||||
|
}catch(e){ toast(e.message, true); }
|
||||||
|
};
|
||||||
|
$('#dsave').onclick = () => {
|
||||||
|
const patch: Record<string, unknown> = {directory_url: $('#durl').value.trim(), directory_share: $('#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();
|
||||||
|
save(patch, 'Directory settings saved');
|
||||||
|
};
|
||||||
|
const clear = $('#dclear');
|
||||||
|
if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared');
|
||||||
|
drawDirectoryStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawDirectoryStatus(){
|
||||||
|
const el = $('#dstatus');
|
||||||
|
let s;
|
||||||
|
try{ s = await api('/api/directory-status'); }catch(e){ el.textContent = e.message; return; }
|
||||||
|
if(!el.isConnected) return;
|
||||||
|
const lines: string[] = [];
|
||||||
|
if(!s.configured) lines.push('Not set up: it needs an address and a token.');
|
||||||
|
else if(s.error) lines.push(esc(s.error));
|
||||||
|
else lines.push(`Connected as <b>${esc(s.name)}</b>, which may ${esc(ACCESS[s.access] || s.access)}.`);
|
||||||
|
const sh = s.shared;
|
||||||
|
if(sh){
|
||||||
|
const when = new Date(sh.at * 1000).toLocaleString();
|
||||||
|
lines.push(sh.error ? `Sharing failed at ${esc(when)}: ${esc(sh.error)}`
|
||||||
|
: `Shared ${plural(sh.accepted, 'feed')} at ${esc(when)}.` +
|
||||||
|
(sh.refused_private ? ` The directory refused ${plural(sh.refused_private, 'feed')} as private.` : '') +
|
||||||
|
(sh.refused_blocked ? ` ${plural(sh.refused_blocked, 'feed')} the directory has removed.` : ''));
|
||||||
|
}
|
||||||
|
el.innerHTML = lines.join('<br>');
|
||||||
|
if(s.access === 'rwa') drawDirectoryAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a token that may administer the directory can change from here (#206): the servers
|
||||||
|
/// 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; }
|
||||||
|
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
|
||||||
|
feeds and counts until it next shares; to keep it out, revoke its token in the directory's config.</p>
|
||||||
|
${a.instances.length ? a.instances.map(i => `<div class="inline urow" data-name="${esc(i.name)}">
|
||||||
|
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(i.name)}</b>
|
||||||
|
<small style="display:block;color:var(--faint)">${plural(i.feeds, 'feed')} · ${plural(i.subscribers, 'subscriber')}</small></div>
|
||||||
|
<button class="btn ico danger" data-a="drop" title="Drop ${esc(i.name)}'s feeds" aria-label="Drop ${esc(i.name)}'s feeds">${ICON.trash}</button></div>`).join('')
|
||||||
|
: '<p class="hint">No server is sharing yet.</p>'}
|
||||||
|
<h2>Blocked</h2>
|
||||||
|
<p class="hint">Feeds removed from the directory. No snapshot brings them back until they are unblocked.</p>
|
||||||
|
${a.blocked.length ? a.blocked.map(b => `<div class="inline urow" data-id="${esc(b.id)}">
|
||||||
|
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(b.title || 'Untitled')}</b>
|
||||||
|
<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 => {
|
||||||
|
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;
|
||||||
|
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'});
|
||||||
|
toast('Unblocked. It comes back with the next snapshot that has it');
|
||||||
|
}
|
||||||
|
drawDirectoryAdmin();
|
||||||
|
}catch(e){ toast(e.message, true); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- accounts ---------------- */
|
/* ---------------- accounts ---------------- */
|
||||||
async function drawAccounts(){
|
async function drawAccounts(){
|
||||||
const box = $('#accounts');
|
const box = $('#accounts');
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/* ---------------- modals ---------------- */
|
/* ---------------- modals ---------------- */
|
||||||
function openModal(html: string, wide?: boolean){
|
/// `cls` names a dialog with a frame of its own. Set whole each time, so it never outlives its dialog.
|
||||||
|
function openModal(html: string, wide?: boolean, cls?: string){
|
||||||
$('#modalCard').innerHTML=html;
|
$('#modalCard').innerHTML=html;
|
||||||
$('#modalCard').classList.toggle('wide',!!wide);
|
$('#modalCard').className='card'+(wide?' wide':'')+(cls?' '+cls:'');
|
||||||
$('#modal').classList.add('on');
|
$('#modal').classList.add('on');
|
||||||
}
|
}
|
||||||
function closeModal(){
|
function closeModal(){
|
||||||
@@ -14,7 +15,6 @@ $('#addFeed').onclick=()=>{
|
|||||||
openModal(`<h3>Add a feed</h3>
|
openModal(`<h3>Add a feed</h3>
|
||||||
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
|
<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>
|
<span class="hint">A Patreon token on its own adds every show from that creator.</span></div>
|
||||||
<div class="field"><label>Folder (optional)</label><input type="text" id="nfolder" placeholder="Defaults to the feed title"></div>
|
|
||||||
<div class="field"><label>Keywords (optional, comma separated)</label>
|
<div class="field"><label>Keywords (optional, comma separated)</label>
|
||||||
<input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
|
<input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
|
||||||
<label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label>
|
<label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label>
|
||||||
@@ -26,7 +26,7 @@ $('#addFeed').onclick=()=>{
|
|||||||
$('#nsave').disabled=true; $('#nsave').title='Adding…';
|
$('#nsave').disabled=true; $('#nsave').title='Adding…';
|
||||||
try{
|
try{
|
||||||
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
|
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
|
||||||
url, folder:$('#nfolder').value.trim()||null, allow_explicit:$('#nexp').checked,
|
url, allow_explicit:$('#nexp').checked,
|
||||||
keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})});
|
keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})});
|
||||||
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
|
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
|
||||||
await loadFeeds(true); selectFeed(r.id);
|
await loadFeeds(true); selectFeed(r.id);
|
||||||
@@ -34,97 +34,261 @@ $('#addFeed').onclick=()=>{
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// What everyone here reads, you included, as a place to start. The rows carry an id, never a
|
// The rows come from the directory through iPX (#205), and carry its id, never a URL, so a key in
|
||||||
// URL, so a key in someone's feed address never reaches this page.
|
// someone's feed address never reaches this page.
|
||||||
const NONE_LISTED='<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
|
const NONE_LISTED='<p class="hint">Nothing in the directory yet. Feeds people subscribe to show up here once their servers share them.</p>';
|
||||||
async function listFeeds(url,box){
|
// Why the directory could not be shown: none set up, or it did not answer. In iPX's words.
|
||||||
let rows=[];
|
let dirError='';
|
||||||
try{ rows=await api(url)||[]; }catch{}
|
|
||||||
box.innerHTML=rows.length?'':NONE_LISTED;
|
|
||||||
for(const p of rows) box.appendChild(listedFeed(p,'childrow'));
|
|
||||||
return rows.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One listed feed: a row in Popular and the Add a feed dialog, a tile in Directory's grid. The
|
/// 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.
|
/// parts are the same either way; the class lays them out. Yours opens its feed; any other, its
|
||||||
function listedFeed(p,cls){
|
/// page in the Directory, to see what it is before you take it (#128).
|
||||||
|
function listedFeed(p,cls,rank?: number){
|
||||||
const el=document.createElement('div');
|
const el=document.createElement('div');
|
||||||
el.className=cls;
|
el.className=cls+(p.subscribed?' mine':'');
|
||||||
el.innerHTML=artHTML(p.image,p.title||p.id)+
|
el.tabIndex=0; el.dataset.id=p.id;
|
||||||
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
|
// As the feed list's rows: Enter or Space opens it, and stops there, or Space would also
|
||||||
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
|
// play or pause.
|
||||||
|
el.onkeydown=ev=>{
|
||||||
|
if((ev.key==='Enter'||ev.key===' ')&&ev.target===el){ ev.preventDefault(); ev.stopPropagation(); el.click(); }
|
||||||
|
};
|
||||||
|
// A chart says what a show is about, as Apple's does; the grid says who here reads it, when
|
||||||
|
// anyone does. "0 subscribers" under most of the grid said nothing.
|
||||||
|
const meta=rank?(p.subcategory||p.category):p.subscribers?plural(p.subscribers,'subscriber'):'';
|
||||||
|
el.innerHTML=(rank?`<span class="no">${rank}</span>`:'')+artHTML(p.image,NAME(p))+
|
||||||
|
`<div class="txt"><b>${esc(NAME(p))}</b>${meta?`<small class="meta">${esc(meta)}</small>`:''}</div>`+
|
||||||
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
|
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
|
||||||
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
|
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
|
||||||
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.subbed}</button>`);
|
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
|
||||||
// Yours already: the row opens it instead.
|
// Yours has an id here as well as the directory's, and opens by it.
|
||||||
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; return el; }
|
if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; }
|
||||||
$('[data-a="sub"]',el).onclick=async()=>{
|
el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); };
|
||||||
try{
|
// Its own click, not the tile's as well.
|
||||||
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
|
$('[data-a="sub"]',el).onclick=ev=>{ ev.stopPropagation(); subscribeListed(p); };
|
||||||
closeModal(); toast(`Subscribed to ${p.title||p.id}`);
|
|
||||||
await loadFeeds(true); selectFeed(p.id);
|
|
||||||
}catch(e){ toast(e.message,true); }
|
|
||||||
};
|
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Directory's filters. Kept out here because a finished scan redraws the pane, which would
|
async function subscribeListed(p){
|
||||||
// otherwise clear them.
|
try{
|
||||||
let dirKind='All', dirCat=null;
|
const r=await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
|
||||||
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
|
toast(`Subscribed to ${NAME(p)}`);
|
||||||
/// Directory: every listed feed as its cover art, under two filters that combine: what a feed is
|
await loadFeeds(true); selectFeed(r.id);
|
||||||
/// (Podcasts, anything with audio or video, or Blogs, the rest) and what it is about (its iTunes
|
}catch(e){ toast(e.message,true); }
|
||||||
/// category, as chips). Both filter in place, without asking the server again.
|
|
||||||
async function renderDirectory(url,box){
|
|
||||||
let rows=[];
|
|
||||||
try{ rows=await api(url)||[]; }catch{}
|
|
||||||
if(!rows.length){ box.innerHTML=NONE_LISTED; return 0; }
|
|
||||||
const bar=$('#dirbar');
|
|
||||||
// Only a filter when the server has both kinds.
|
|
||||||
const both=rows.some(KINDS.Podcasts)&&rows.some(KINDS.Blogs);
|
|
||||||
const btn=(k,v,on)=>`<button type="button" data-${k}="${esc(v)}" class="${on?'on':''}" aria-pressed="${on}">${esc(v)}</button>`;
|
|
||||||
const draw=()=>{
|
|
||||||
if(!both) dirKind='All';
|
|
||||||
const ofKind=rows.filter(KINDS[dirKind]);
|
|
||||||
// No empty chips: only the categories among the feeds the kind lets through.
|
|
||||||
const cats=[...new Set(ofKind.map(p=>p.category).filter(Boolean))].sort();
|
|
||||||
if(!cats.includes(dirCat)) dirCat=null;
|
|
||||||
bar.innerHTML=
|
|
||||||
(both?`<div class="tabs" role="group" aria-label="Kind">${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}</div>`:'')+
|
|
||||||
(cats.length?`<div class="chips" role="group" aria-label="Category">${cats.map(c=>btn('cat',c,c===dirCat)).join('')}</div>`:'');
|
|
||||||
// A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to
|
|
||||||
// the button just pressed.
|
|
||||||
for(const b of $$('button',bar)) b.onclick=()=>{
|
|
||||||
const k=b.dataset.kind!=null?'kind':'cat', v=b.dataset[k];
|
|
||||||
if(k==='kind') dirKind=v; else dirCat=dirCat===v?null:v;
|
|
||||||
draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus();
|
|
||||||
};
|
|
||||||
box.innerHTML='';
|
|
||||||
for(const p of ofKind.filter(p=>!dirCat||p.category===dirCat)) box.appendChild(listedFeed(p,'tile'));
|
|
||||||
};
|
|
||||||
draw();
|
|
||||||
return rows.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Directory and Popular open in the main pane, as the original's Directory did.
|
// 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=[];
|
||||||
|
// The feed whose page is open over all that, by id; going back leaves the rest as it was.
|
||||||
|
let dirShow=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);
|
||||||
|
/// The grid's orders. It opens A to Z, as the server sends it.
|
||||||
|
const DIR_SORTS={
|
||||||
|
subscribers:['Most subscribers',bySubs],
|
||||||
|
az:['Name, A to Z',byName],
|
||||||
|
za:['Name, Z to A',(a,b)=>byName(b,a)],
|
||||||
|
};
|
||||||
|
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
|
||||||
|
const KIND_WORD={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){
|
||||||
|
// 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; }
|
||||||
|
drawDirectory(box);
|
||||||
|
return dirRows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `focus` is the control to put the keyboard back on, since the whole page is redrawn.
|
||||||
|
function drawDirectory(box=$('#directory'), focus?: string){
|
||||||
|
if(!box) return;
|
||||||
|
if(!dirRows.length){ box.innerHTML=dirError?`<p class="hint">${esc(dirError)}</p>`:NONE_LISTED; return; }
|
||||||
|
// Only a choice when the server has both kinds.
|
||||||
|
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);
|
||||||
|
// 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>`;
|
||||||
|
// back: where its button goes, '' for the front; none on the front itself.
|
||||||
|
const head=(title,sub,back?)=>`<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 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;
|
||||||
|
const show=dirShow&&dirRows.find(p=>p.id===dirShow);
|
||||||
|
if(show){
|
||||||
|
const n=show.subscribers;
|
||||||
|
html=`<div class="dhead"><button type="button" class="dback" data-back>${ICON.left}${esc(q?'Results':dirCat?catName(dirCat):'Directory')}</button></div>
|
||||||
|
<div class="show">${artHTML(show.image,NAME(show))}<div class="meta"><h2>${esc(NAME(show))}</h2>${
|
||||||
|
// Its category's page is one click away from here.
|
||||||
|
show.category?`<button type="button" class="dlink" data-go="${esc(show.category)}">${esc(show.subcategory||show.category)}</button>`:''}
|
||||||
|
${n?`<p class="sub">${n} ${n===1?'person subscribes':'people subscribe'}</p>`:''}
|
||||||
|
<p class="sub dfail" id="dfail" hidden></p>
|
||||||
|
<div class="acts"><button class="btn ico primary" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button></div></div></div>
|
||||||
|
<div class="about" id="dabout" hidden><p></p><button type="button" class="dlink" aria-expanded="false" hidden>More</button></div>`
|
||||||
|
+sec(show.podcast?'Latest episodes':'Latest posts','<div class="latest" id="dshow"><p class="hint">Loading…</p></div>');
|
||||||
|
}else if(q){
|
||||||
|
// Names only: a category is a tap away, and "news" matching every News feed buried the
|
||||||
|
// one called that.
|
||||||
|
tiles=rows.filter(p=>NAME(p).toLocaleLowerCase().includes(q.toLocaleLowerCase()));
|
||||||
|
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); }
|
||||||
|
// 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>`,
|
||||||
|
`<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;
|
||||||
|
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);
|
||||||
|
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.
|
||||||
|
+sec(chart.length?`${word[0].toUpperCase()+word.slice(1)}s`:'',tools(subs.length?`<div class="pills" role="group" aria-label="Subcategory">${
|
||||||
|
pill('','All',inCat.length)+subs.map(s=>pill(s,s,inCat.filter(p=>p.subcategory===s).length)).join('')}</div>`:'')+grid);
|
||||||
|
}
|
||||||
|
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))); }
|
||||||
|
const t=$('#dtiles',box);
|
||||||
|
if(t) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile'));
|
||||||
|
box.onclick=ev=>{
|
||||||
|
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.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=''; }
|
||||||
|
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);
|
||||||
|
if(s) s.onchange=()=>{ dirSort=s.value; drawDirectory(box,'#dirsort'); };
|
||||||
|
if(focus) $(focus,box)?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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){
|
||||||
|
const list=$('#dshow',box), about=$('#dabout',box);
|
||||||
|
let got;
|
||||||
|
try{ got=await api(`/api/directory/${encodeURIComponent(p.id)}`); }
|
||||||
|
catch(e){ list.innerHTML=`<p class="hint">${esc(e.message)}</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.
|
||||||
|
const said=plainText(got.description);
|
||||||
|
if(said&&said!==NAME(p)){
|
||||||
|
const text=$('p',about), more=$('button',about);
|
||||||
|
text.textContent=said; about.hidden=false;
|
||||||
|
// Only when there is more than the three lines shown.
|
||||||
|
more.hidden=text.scrollHeight<=text.clientHeight+1;
|
||||||
|
more.onclick=()=>{
|
||||||
|
const open=about.classList.toggle('open');
|
||||||
|
more.textContent=open?'Less':'More'; more.setAttribute('aria-expanded',String(open));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// A broken feed says so, or it reads as a quiet one (#142).
|
||||||
|
if(got.failing){ const f=$('#dfail',box); f.textContent=got.failing; f.hidden=false; }
|
||||||
|
// An admin whose token may administer the directory (#206); once, as Refresh fills it again.
|
||||||
|
const acts=$('.show .acts',box);
|
||||||
|
if(got.can_admin&&!$('[data-a="dremove"]',acts)){
|
||||||
|
acts.insertAdjacentHTML('beforeend',
|
||||||
|
`<button class="btn ico" data-a="drefresh" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>`
|
||||||
|
+`<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); }
|
||||||
|
};
|
||||||
|
$('[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); }
|
||||||
|
toast(`Removed ${NAME(p)} from the directory`);
|
||||||
|
dirRows=dirRows.filter(r=>r.id!==p.id); dirShow=null;
|
||||||
|
drawDirectory(box);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const items=got.items;
|
||||||
|
list.innerHTML=items.length?items.map(e=>{
|
||||||
|
const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500);
|
||||||
|
const when=[dateOf(e.published),e.duration&&clock(e.duration)].filter(Boolean);
|
||||||
|
return `<article class="lep"><b>${e.link?`<a href="${esc(e.link)}" target="_blank" rel="noopener">${esc(name.text)}</a>`:esc(name.text)}</b>`
|
||||||
|
+(text?`<p>${esc(text)}</p>`:'')
|
||||||
|
+(when.length?`<small>${when.map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}</small>`:'')+'</article>';
|
||||||
|
}).join(''):'<p class="hint">Nothing read from it yet. The directory checks each feed every few hours.</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){
|
||||||
|
// 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();
|
||||||
|
return `<button type="button" class="cat" data-go="${esc(c)}"><b>${esc(name)}</b><small>${count}</small>`+
|
||||||
|
`<span class="fan" aria-hidden="true">${art.map(p=>artHTML(p.image,NAME(p))).join('')}</span></button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Directory and Currently Listening open in the main pane, as the original's Directory did.
|
||||||
async function renderListed(v){
|
async function renderListed(v){
|
||||||
const box=$('#content');
|
const box=$('#content');
|
||||||
box.classList.add('plain');
|
box.classList.add('plain');
|
||||||
$('#tbRemove').disabled=true;
|
$('#tbRemove').disabled=true;
|
||||||
syncTools(null);
|
syncTools(null);
|
||||||
|
if(v===VIEWS[':directory']){
|
||||||
|
$('#epSearch').placeholder='Find a feed…';
|
||||||
|
box.innerHTML='<div id="directory"><p class="hint">Loading…</p></div>';
|
||||||
|
$('#count').textContent=v.title;
|
||||||
|
const n=await renderDirectory(v.url,$('#directory'));
|
||||||
|
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
$('#epSearch').placeholder='Search items…';
|
$('#epSearch').placeholder='Search items…';
|
||||||
const listening=v===VIEWS[':listening'], grid=v===VIEWS[':directory'];
|
|
||||||
box.innerHTML=`
|
box.innerHTML=`
|
||||||
<div class="fhead slim">
|
<div class="fhead slim">
|
||||||
<div class="art">${v.icon}</div>
|
<div class="art">${v.icon}</div>
|
||||||
<div class="meta"><h2>${v.title}</h2>
|
<div class="meta"><h2>${v.title}</h2><div class="sub">${v.blurb}</div></div>
|
||||||
<div class="sub">${v.blurb}${listening?'':' Everyone counts, you included. Private feeds are never listed.'}</div></div>
|
|
||||||
</div>
|
</div>
|
||||||
${grid?'<div class="dirbar" id="dirbar"></div>':''}
|
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`;
|
||||||
<div class="${grid?'tiles':'childlist'}" id="${listening?'listening':'popular'}"><p class="hint">Loading…</p></div>`;
|
|
||||||
$('#count').textContent=v.title;
|
$('#count').textContent=v.title;
|
||||||
const n=await (listening?renderListening:grid?renderDirectory:listFeeds)(v.url,$(listening?'#listening':'#popular',box));
|
// The search box looks through these too, title and text, as it does a feed's items (#127).
|
||||||
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,listening?'episode':'feed')}`;
|
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')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Currently Listening: episodes you started and have not finished, across every feed you
|
/// Currently Listening: episodes you started and have not finished, across every feed you
|
||||||
@@ -133,14 +297,15 @@ async function renderListed(v){
|
|||||||
async function renderListening(url,box){
|
async function renderListening(url,box){
|
||||||
let rows=[];
|
let rows=[];
|
||||||
try{ rows=(await api(url)).entries||[]; }catch{}
|
try{ rows=(await api(url)).entries||[]; }catch{}
|
||||||
box.innerHTML=rows.length?'':'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
|
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){
|
for(const e of rows){
|
||||||
// Carries its episode, for paintListenRow to repaint as the player moves.
|
// Carries its episode, for paintListenRow to repaint as the player moves.
|
||||||
const el: HTMLDivElement & {entry?: any}=document.createElement('div');
|
const el: HTMLDivElement & {entry?: any}=document.createElement('div');
|
||||||
el.className='childrow';
|
el.className='childrow';
|
||||||
el.entry=e;
|
el.entry=e;
|
||||||
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
|
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
|
||||||
`<div class="txt"><b>${EQ}<span>${esc(e.title||'(untitled)')}</span></b>`+
|
`<div class="txt"><b>${EQ}<span>${esc(entryName(e).text)}</span></b>`+
|
||||||
`<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+
|
`<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+
|
||||||
`<button class="iconbtn" data-a="play"></button>`+
|
`<button class="iconbtn" data-a="play"></button>`+
|
||||||
`<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+
|
`<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+
|
||||||
@@ -193,7 +358,12 @@ $('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
|
|||||||
$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
|
$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
|
||||||
let searchT;
|
let searchT;
|
||||||
$('#epSearch').oninput=ev=>{ clearTimeout(searchT);
|
$('#epSearch').oninput=ev=>{ clearTimeout(searchT);
|
||||||
searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; loadEntries(); },250); };
|
// 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;
|
||||||
|
if(S.feed===':directory'){ dirShow=null; drawDirectory(); }
|
||||||
|
else if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
||||||
|
else loadEntries(); },250); };
|
||||||
// Crossing the phone breakpoint moves the files between their pane and the text.
|
// Crossing the phone breakpoint moves the files between their pane and the text.
|
||||||
window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); });
|
window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); });
|
||||||
|
|
||||||
@@ -215,7 +385,15 @@ function due(ts){
|
|||||||
/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does
|
/// 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
|
/// 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.
|
/// admin is sent (issue #19); this used to hold them too, shown to admins only.
|
||||||
|
function deadSavedText(me){
|
||||||
|
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.';
|
||||||
|
}
|
||||||
|
|
||||||
async function prefsModal(){
|
async function prefsModal(){
|
||||||
|
// Fresh, so the time saved counts what was heard since the page loaded.
|
||||||
|
api('/api/me').then(u=>{ S.me=u; const h=$('#sdeadsaved'); if(h) h.textContent=deadSavedText(u); }).catch(()=>{});
|
||||||
const g = await api('/api/settings');
|
const g = await api('/api/settings');
|
||||||
const admin = !!(S.me&&S.me.admin);
|
const admin = !!(S.me&&S.me.admin);
|
||||||
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
|
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
|
||||||
@@ -226,6 +404,8 @@ async function prefsModal(){
|
|||||||
<select id="smode">${Object.entries(MODES).map(([k,t])=>
|
<select id="smode">${Object.entries(MODES).map(([k,t])=>
|
||||||
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
|
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
|
||||||
<span class="hint">Auto follows your system's light/dark setting.</span></div>
|
<span class="hint">Auto follows your system's light/dark setting.</span></div>
|
||||||
|
<div class="field"><label>DeadAir</label>
|
||||||
|
<span class="hint" id="sdeadsaved">${deadSavedText(S.me)}</span></div>
|
||||||
<div class="field"><label>Subscriptions</label>
|
<div class="field"><label>Subscriptions</label>
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<!-- Words as well as icons: a floppy disk and a plus mean nothing on their own here. -->
|
<!-- Words as well as icons: a floppy disk and a plus mean nothing on their own here. -->
|
||||||
@@ -239,14 +419,35 @@ async function prefsModal(){
|
|||||||
<span class="hint">Comma separated words or phrases, in every feed you read. An item with
|
<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
|
one in its title or text is hidden from you and not downloaded for you. Each feed's
|
||||||
settings can add more.</span></div>
|
settings can add more.</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">
|
||||||
|
<button class="btn" id="tokadd" title="Make an API token" aria-label="Make an API token">${ICON.plus} Make</button></div>
|
||||||
|
<span class="hint">A token lets a script or an agent use iPX as you, adding and removing
|
||||||
|
your feeds and reading your items: it sends <code>Authorization: Bearer</code> and the token.
|
||||||
|
Anyone holding one is you here, so revoke one you no longer use.</span></div>
|
||||||
<div class="field"><label>Feeds are checked every</label>
|
<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.
|
<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>
|
${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>`);
|
||||||
<div class="field"><label>Download folder</label>
|
|
||||||
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>`);
|
|
||||||
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
||||||
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
||||||
$('#gopml').onclick=opmlModal;
|
$('#gopml').onclick=opmlModal;
|
||||||
|
drawTokens();
|
||||||
|
$('#tokadd').onclick=async()=>{
|
||||||
|
const name=$('#tokname').value.trim(); if(!name){ $('#tokname').focus(); return; }
|
||||||
|
try{
|
||||||
|
const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name})});
|
||||||
|
$('#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); }
|
||||||
|
};
|
||||||
$('#sblock').onchange=async e=>{
|
$('#sblock').onchange=async e=>{
|
||||||
const blocked=splitWords(e.target.value);
|
const blocked=splitWords(e.target.value);
|
||||||
try{
|
try{
|
||||||
@@ -257,75 +458,211 @@ async function prefsModal(){
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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(()=>[]);
|
||||||
|
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); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const splitWords=(s: string)=>s.split(',').map(w=>w.trim()).filter(Boolean);
|
const splitWords=(s: string)=>s.split(',').map(w=>w.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
/// Words as chips, for the words that pick or hide items: each word or phrase ends with Enter or a
|
||||||
|
/// comma, and comes off with its x or Backspace. One comma-separated box ran one phrase into the
|
||||||
|
/// next (#167).
|
||||||
|
const chipHTML=(w: string)=>`<span class="chip" data-w="${esc(w)}">${esc(w)}<button type="button" class="chipx"
|
||||||
|
title="Remove ${esc(w)}" aria-label="Remove ${esc(w)}">${ICON.close}</button></span>`;
|
||||||
|
/// What goes inside a chip box. The box itself is written out where it is used, id and all: the page
|
||||||
|
/// check finds ids by reading them, and one put together here would read as missing.
|
||||||
|
const CHIP_HINT='Add a word or phrase';
|
||||||
|
function chipsHTML(words: string[], input: string){
|
||||||
|
return `${words.map(chipHTML).join('')}<input type="text" class="chipin" id="${input}"
|
||||||
|
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 wireChips(box){
|
||||||
|
const input=$('.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());
|
||||||
|
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=>{
|
||||||
|
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(); }
|
||||||
|
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, newUrl?: string){
|
||||||
const isGroup = S.feeds.some(c=>c.group===f.id);
|
const kids=S.feeds.filter(c=>c.group===f.id).length;
|
||||||
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
const isGroup=kids>0;
|
||||||
${isGroup?`<p class="hint" style="margin:-6px 0 12px">This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These
|
const parent=f.managed?S.feeds.find(p=>p.id===f.group):null;
|
||||||
settings apply to it and are inherited by every feed inside it.</p>`:''}
|
const admin=!!(S.me&&S.me.admin);
|
||||||
${f.managed?`<p class="hint" style="margin:-6px 0 12px">This feed comes from
|
const name=f.title||f.id;
|
||||||
${isPatreon(S.feeds.find(p=>p.id===f.group))?'a Patreon creator':'an OPML subscription'} and follows its settings. Saving anything here gives it its own entry in
|
// Blank follows the server's default; 0 is every item (#166).
|
||||||
config.toml, and it stops following the subscription's settings.</p>`:''}
|
let mode=f.max_new_per_check==null?'default':f.max_new_per_check===0?'all':'newest';
|
||||||
<p class="hint" style="margin:-4px 0 10px">These are <b>your</b> settings for this feed.
|
let newest=f.max_new_per_check>0?f.max_new_per_check:(globalMax||3);
|
||||||
Everyone else keeps their own.</p>
|
const whose=isGroup
|
||||||
<div class="field"><label>Keywords</label>
|
? `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.`
|
||||||
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
|
: parent
|
||||||
<span class="hint">Comma separated. Empty takes everything.</span></div>
|
? `It comes from ${esc(parent.title||parent.id)}, and follows your settings there until you save these.`
|
||||||
${isGroup?'':`<div class="field"><label for="sblock">Hide items mentioning</label>
|
: 'Your settings for this feed. Everyone else who reads it keeps their own.';
|
||||||
<input type="text" id="sblock" value="${esc((f.blocked||[]).join(', '))}">
|
const sw=(input: string, text: string, cls='')=>`<label class="switch${cls}"><span>${text}</span><span class="sw">${input}<i></i></span></label>`;
|
||||||
<span class="hint">Comma separated words or phrases. An item with one in its title or text
|
openModal(`<div class="fsetbody">
|
||||||
is hidden from you and not downloaded for you, as well as those your Settings hide
|
<header class="fsethead">${artHTML(f.image,name)}<div><h3>${esc(name)}</h3><p>${whose}</p></div></header>
|
||||||
everywhere.</span></div>`}
|
<div class="field faddr"><label for="surl">Address</label>
|
||||||
<div class="field"><label>Max new downloads per scan</label>
|
<div class="inline"><input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false"${admin?'':' readonly'}>
|
||||||
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
|
<button type="button" class="btn ico" id="scopy" title="Copy the address" aria-label="Copy the address">${ICON.copy}</button></div>
|
||||||
<span class="hint">Blank follows the global default (${globalMax}). The rest wait for
|
${newUrl?'<p class="moved">The feed says it has moved here. Save to follow it.</p>':''}</div>
|
||||||
the next scan.</span></div>
|
<div class="fdl">${sw(`<input type="checkbox" role="switch" id="sauto"${f.auto_download?' checked':''}>`,'Download automatically',' lead')}
|
||||||
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label>
|
<div class="fdeps${f.auto_download?'':' off'}" id="sdeps"><div><div class="fdepsin">
|
||||||
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label>
|
<div class="tabs seg" role="radiogroup" aria-label="How many to download">${
|
||||||
<div class="field"><label>Feed URL</label>
|
[['default',`Default (${globalMax||'all'})`],['newest','Newest'],['all','All']].map(([v,t])=>
|
||||||
<div class="inline">
|
`<label><input type="radio" name="swin" value="${v}"${mode===v?' checked':''}>${t}</label>`).join('')}</div>
|
||||||
<input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
|
<div class="epwin" id="sepwin" aria-hidden="true"></div>
|
||||||
<button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.copy}</button>
|
<p class="wcap" id="swcap"></p>
|
||||||
|
<div class="ffield"><label class="flabel" for="skwin">Only items that mention</label>
|
||||||
|
<div class="chips" id="skw">${chipsHTML(f.keywords,'skwin')}</div>
|
||||||
|
<p class="cap">Any one of these will do, and a phrase needs all its words. Empty takes everything.</p></div>
|
||||||
|
${sw(`<input type="checkbox" role="switch" id="sexp"${f.allow_explicit?' checked':''}>`,'Include explicit items')}
|
||||||
|
</div></div></div>
|
||||||
|
<div class="ffield fdead">${sw(`<input type="checkbox" role="switch" id="sdead"${f.deadair?' checked':''}>`,'DeadAir',' lead')}
|
||||||
|
<p class="cap">Cuts the dead air: long silences in ${isGroup?'these shows':'this show'} shrink to a beat, so
|
||||||
|
episodes finish sooner and every voice keeps its own pace. Leave it off for stories whose pauses
|
||||||
|
are part of the telling.</p>
|
||||||
|
<div class="dpause">
|
||||||
|
<div class="dphead"><label class="flabel" for="sdpause">Breathing room</label><output id="sdpauseout" for="sdpause"></output></div>
|
||||||
|
<div class="dpslide"><input type="range" id="sdpause" min="200" max="500" step="100" value="${f.deadair_pause||300}"${f.deadair?'':' disabled'}></div>
|
||||||
|
<div class="dpends" aria-hidden="true"><span>Brisk</span><span>Relaxed</span></div>
|
||||||
|
<p class="cap">The silence kept where a long one is cut. More is easier on the ear; less saves more time.</p></div></div></div>
|
||||||
|
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
|
||||||
|
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
|
||||||
|
<p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along
|
||||||
|
with what Settings hides everywhere.</p></div>`}
|
||||||
|
<div class="ffield">${sw(`<input type="checkbox" role="switch" id="sprivate"${f.private||f.private_by_address?' checked':''}${
|
||||||
|
f.private_by_address||(f.private&&!admin)?' disabled':''}>`,'Private',' lead')}
|
||||||
|
<p class="cap">${f.private_by_address
|
||||||
|
?'Its address or login shows it is someone’s own, so it is never shared with the directory.'
|
||||||
|
:f.private&&!admin
|
||||||
|
?'Never shared with the directory. Only an admin can share it again.'
|
||||||
|
:`Never shared with the directory, for ${isGroup?'shows':'a show'} you pay for. iPX knows most paid feeds by their address; this is for the ones it does not.`}</p></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="hint">${S.me&&S.me.admin
|
<div class="cardacts fsetfoot"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
? `Shared with everyone reading this feed. Editing it keeps every item and download —
|
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`, false, 'fset');
|
||||||
handy when an auth token in the URL is rotated. The feed is re-checked from scratch
|
|
||||||
on the next scan.`
|
// What only matters while it downloads on its own folds away, out of reach, when it does not.
|
||||||
: `The same for everyone reading this feed, so only an admin can change it.`}</span></div>
|
const deps=$('#sdeps'), inner=$('.fdepsin',deps);
|
||||||
${S.me&&S.me.admin?`<div class="field"><label>Download folder (shared)</label>
|
inner.inert=!f.auto_download;
|
||||||
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}">
|
$('#sauto').onchange=e=>{ deps.classList.toggle('off',!e.target.checked); inner.inert=!e.target.checked; };
|
||||||
<span class="hint">Where the files land. There is one copy however many people
|
|
||||||
subscribe, so this is the same for everyone.</span></div>`:''}
|
// The download limit, said as what it does. A number in a box hid that it is a window on the
|
||||||
${S.me&&S.me.admin?`<div class="field"><label>Directory category (shared)</label>
|
// newest items, not a pace through the back catalogue (#97).
|
||||||
<input type="text" id="scat" list="scats" value="${esc(f.category||'')}" placeholder="${esc(f.feed_category||'None')}">
|
const each=isGroup?' of each show':'';
|
||||||
<datalist id="scats"></datalist>
|
const older=' Older ones stay listed, to download by hand.';
|
||||||
<span class="hint">${f.feed_category
|
const caption=()=>mode==='newest'
|
||||||
? `The feed names its own, ${esc(f.feed_category)}, and the Directory uses that.`
|
? `Downloads the newest <input type="number" id="smax" min="1" value="${newest}"
|
||||||
: `The feed names none, so the Directory files it under this. Pick one already listed where it fits.`}</span></div>`:''}
|
aria-label="How many of the newest to download">${each} on its own.${older}`
|
||||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
: mode==='all'
|
||||||
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
|
? `Downloads every item${each}: the whole back catalogue${!isGroup&&f.entries?`, ${f.entries} so far`:''}, and each new one.`
|
||||||
|
: globalMax
|
||||||
|
? `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=[];
|
||||||
|
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.
|
||||||
|
let left=limit();
|
||||||
|
// Everything: the older ones past the strip are lit too, in words.
|
||||||
|
$('#sepwin')?.classList.toggle('all',left===Infinity);
|
||||||
|
for(const b of bars){
|
||||||
|
const on=!b.classList.contains('nofile')&&left>0;
|
||||||
|
if(on) left--;
|
||||||
|
b.classList.toggle('on',on);
|
||||||
|
}
|
||||||
|
if(!words) return;
|
||||||
|
$('#swcap').innerHTML=caption();
|
||||||
|
const n=$('#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); };
|
||||||
|
// 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); };
|
||||||
|
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 strip=$('#sepwin'); if(!items.length||!strip) return;
|
||||||
|
const longest=Math.max(1,...items.map(e=>e.duration||0));
|
||||||
|
strip.innerHTML=items.map((e,i)=>{
|
||||||
|
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>`;
|
||||||
|
}).join('')+(f.entries>items.length?`<small>+${f.entries-items.length} older</small>`:'');
|
||||||
|
bars=$$('i',strip);
|
||||||
|
draw(false);
|
||||||
|
// Drag along it to set how many: as far as the finger, the newest that far down.
|
||||||
|
const pick=(x: number)=>{
|
||||||
|
let n=0;
|
||||||
|
for(const b of bars){
|
||||||
|
if(!b.classList.contains('nofile')) n++;
|
||||||
|
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); }
|
||||||
|
};
|
||||||
|
strip.onpointerdown=e=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
|
||||||
|
strip.onpointermove=e=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); };
|
||||||
|
}).catch(()=>{});
|
||||||
|
|
||||||
|
wireChips($('#skw'));
|
||||||
|
if($('#sblock')) wireChips($('#sblock'));
|
||||||
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
|
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
|
||||||
// Offer the categories the Directory already shows, so a blog about games joins Games rather
|
// Opened to follow a feed that moved: the new address is what there is to look at.
|
||||||
// than starting a second chip beside it.
|
if(newUrl){ $('#surl').scrollIntoView({block:'center'}); $('#surl').focus(); }
|
||||||
if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[])
|
|
||||||
.map(p=>p.category).filter(Boolean))].sort().map(c=>`<option value="${esc(c)}">`).join(''); }).catch(()=>{});
|
|
||||||
$('#ssave').onclick=async()=>{
|
$('#ssave').onclick=async()=>{
|
||||||
const max=$('#smax').value;
|
|
||||||
try{
|
try{
|
||||||
const patch: Record<string, unknown>={
|
const patch: Record<string, unknown>={
|
||||||
keywords:splitWords($('#skw').value),
|
keywords:chipWords($('#skw')),
|
||||||
max_new_per_check:max===''?null:Number(max),
|
max_new_per_check:mode==='default'?null:mode==='all'?0:newest,
|
||||||
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
|
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked,
|
||||||
if($('#sblock')) patch.blocked=splitWords($('#sblock').value);
|
deadair_pause:Number($('#sdpause').value)};
|
||||||
// The shared half is an admin's to change, and the API refuses it from anyone else.
|
if($('#sblock')) patch.blocked=chipWords($('#sblock'));
|
||||||
if(S.me&&S.me.admin){
|
// Sent only when it changed: anyone may mark a feed private, but only an admin unmark it.
|
||||||
patch.url=$('#surl').value.trim();
|
const priv=$('#sprivate');
|
||||||
patch.folder=$('#sfolder').value.trim()||null;
|
if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked;
|
||||||
patch.category=$('#scat').value.trim()||null;
|
// 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();
|
||||||
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
|
||||||
closeModal(); toast('Saved — applies on the next scan');
|
closeModal(); toast('Saved. It applies from the next check.');
|
||||||
await loadFeeds(true); renderFeed(); loadEntries();
|
await loadFeeds(true); renderFeed(); loadEntries();
|
||||||
}catch(e){ toast(e.message,true); }
|
}catch(e){ toast(e.message,true); }
|
||||||
};
|
};
|
||||||
@@ -401,6 +738,13 @@ $('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); locat
|
|||||||
api('/api/me').then(u=>{
|
api('/api/me').then(u=>{
|
||||||
S.me=u;
|
S.me=u;
|
||||||
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
||||||
|
// ipx's version and the page's, each released on its own.
|
||||||
|
$('#applogo').title=`iPX ${u.version}, page __WEB_VERSION__`;
|
||||||
|
// Whether the link to Admin shows, now and before the first frame of the next visit.
|
||||||
|
document.documentElement.toggleAttribute('data-admin',!!u.admin);
|
||||||
|
try{ localStorage.setItem('ipx.admin',u.admin?'1':'0'); }catch{}
|
||||||
|
// 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(()=>{});
|
}).catch(()=>{});
|
||||||
$('#feedFilter').oninput=renderFeeds;
|
$('#feedFilter').oninput=renderFeeds;
|
||||||
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
|
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
/* ---------------- live events ---------------- */
|
/* ---------------- live events ---------------- */
|
||||||
let sse;
|
let sse;
|
||||||
|
/// 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(()=>{
|
||||||
|
loadFeeds(true).catch(()=>{});
|
||||||
|
if(S.feed) loadEntries().catch(()=>{});
|
||||||
|
},300); }; })();
|
||||||
|
document.addEventListener('visibilitychange',()=>{ if(!document.hidden) catchUp(); });
|
||||||
|
let dropped=false;
|
||||||
function connect(){
|
function connect(){
|
||||||
sse=new EventSource('/api/events');
|
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 soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
||||||
const refreshFeeds=soon(()=>loadFeeds(true));
|
|
||||||
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
||||||
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
// 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=>S.feeds.some(f=>f.id===id);
|
||||||
@@ -22,7 +33,14 @@ function connect(){
|
|||||||
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
|
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
|
||||||
// everyone's feeds used to announce themselves to everyone.
|
// everyone's feeds used to announce themselves to everyone.
|
||||||
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
|
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
|
||||||
refreshEntries(); refreshFeeds();
|
refreshEntries();
|
||||||
|
}
|
||||||
|
// A copy finished: the episode's "Cutting dead air…" becomes how much shorter it is.
|
||||||
|
else if(ev.ev==='deadair_done'){
|
||||||
|
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(()=>{});
|
||||||
}
|
}
|
||||||
else if(ev.ev==='download_error'){
|
else if(ev.ev==='download_error'){
|
||||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||||
@@ -35,14 +53,21 @@ function connect(){
|
|||||||
else if(ev.ev==='feed_done'){
|
else if(ev.ev==='feed_done'){
|
||||||
setScanning(ev.feed,false);
|
setScanning(ev.feed,false);
|
||||||
if(!mine(ev.feed)) return;
|
if(!mine(ev.feed)) return;
|
||||||
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
|
if(ev.feed===S.feed||S.feed===':all') refreshEntries();
|
||||||
}
|
}
|
||||||
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
|
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
|
||||||
// red ! marks the feed instead, and its page says why.
|
// red ! marks the feed instead, and its page says why.
|
||||||
else if(ev.ev==='feed_error'){ setScanning(ev.feed,false); if(mine(ev.feed)) refreshFeeds(); }
|
else if(ev.ev==='feed_error') setScanning(ev.feed,false);
|
||||||
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); refreshFeeds(); refreshEntries(); }
|
// 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);
|
||||||
|
// 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(); }
|
||||||
};
|
};
|
||||||
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
|
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
|
||||||
}
|
}
|
||||||
connect();
|
connect();
|
||||||
loadFeeds();
|
loadFeeds();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* ---------------- feed page ---------------- */
|
/* ---------------- feed page ---------------- */
|
||||||
function renderFeed(){
|
function renderFeed(){
|
||||||
|
paintScanning(); // another feed's page may be the one open now
|
||||||
const box=$('#content');
|
const box=$('#content');
|
||||||
box.classList.remove('plain');
|
box.classList.remove('plain');
|
||||||
const v=VIEWS[S.feed];
|
const v=VIEWS[S.feed];
|
||||||
@@ -27,7 +28,7 @@ function renderFeed(){
|
|||||||
${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
|
${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
|
||||||
</div>
|
</div>
|
||||||
<div class="acts">
|
<div class="acts">
|
||||||
<button class="btn ico primary" data-a="scan" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>
|
<button class="btn ico" data-a="scan" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>
|
||||||
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
|
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
|
||||||
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||||
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
||||||
@@ -44,7 +45,7 @@ function renderFeed(){
|
|||||||
subscribe to, newest first · ${unreadAll} unread</div>
|
subscribe to, newest first · ${unreadAll} unread</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="acts">
|
<div class="acts">
|
||||||
<button class="btn ico primary" data-a="scanall" title="Check every feed now" aria-label="Check every feed now">${ICON.scan}</button>
|
<button class="btn ico" data-a="scanall" title="Check every feed now" aria-label="Check every feed now">${ICON.scan}</button>
|
||||||
<button class="btn ico" data-a="readall" title="Mark everything read" aria-label="Mark everything read">${ICON.checks}</button>
|
<button class="btn ico" data-a="readall" title="Mark everything read" aria-label="Mark everything read">${ICON.checks}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`) + `
|
</div>`) + `
|
||||||
@@ -118,7 +119,7 @@ function renderGroup(f,kids){
|
|||||||
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
|
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
|
||||||
</div>
|
</div>
|
||||||
<div class="acts">
|
<div class="acts">
|
||||||
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
|
<button class="btn ico" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
|
||||||
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||||
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
||||||
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>
|
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
/* ---------------- feeds ---------------- */
|
/* ---------------- feeds ---------------- */
|
||||||
|
/// 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){
|
||||||
|
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){
|
async function loadFeeds(keepSel?: boolean){
|
||||||
S.feeds = await api('/api/feeds');
|
S.feeds = await api('/api/feeds');
|
||||||
api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
|
// 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(()=>{});
|
||||||
renderFeeds();
|
renderFeeds();
|
||||||
// Land back where you were; a feed you no longer subscribe to, or a first visit, goes to
|
// Land back where you were; a feed you no longer subscribe to, or a first visit, goes to
|
||||||
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
|
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
|
||||||
@@ -17,10 +28,8 @@ const unreadFirst=(a,b)=>Number(b.unread>0)-Number(a.unread>0);
|
|||||||
// The original's source list opened with these, above the feeds. They are places, not feeds:
|
// 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.
|
// an id starting with ':' can never be a feed's, since feed ids are slugs.
|
||||||
const VIEWS={
|
const VIEWS={
|
||||||
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory',
|
// The Directory holds the most subscribed feeds too, which had a place of their own, Popular (#124).
|
||||||
blurb:'Every feed anyone on this server subscribes to, A to Z. The feeds inside an OPML are listed one by one, not the OPML.'},
|
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory'},
|
||||||
':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular',
|
|
||||||
blurb:'The ten feeds with the most subscribers here. The feeds inside an OPML count one by one, not the OPML.'},
|
|
||||||
':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50',
|
':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.'},
|
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},
|
':all':{title:'All Subscriptions',icon:ICON.all},
|
||||||
@@ -39,6 +48,11 @@ function paintScanning(){
|
|||||||
row.classList.toggle('scanning',on);
|
row.classList.toggle('scanning',on);
|
||||||
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
|
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)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFeeds(){
|
function renderFeeds(){
|
||||||
@@ -52,6 +66,11 @@ function renderFeeds(){
|
|||||||
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
|
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
|
||||||
if(row) (back[1]&&$('.chev',row)||row).focus();
|
if(row) (back[1]&&$('.chev',row)||row).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); }
|
||||||
list.innerHTML='';
|
list.innerHTML='';
|
||||||
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
|
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
|
||||||
const places=document.createElement('div');
|
const places=document.createElement('div');
|
||||||
@@ -100,24 +119,32 @@ function renderFeeds(){
|
|||||||
// went wrong (failBannerHTML); the list only has to make it findable.
|
// went wrong (failBannerHTML); the list only has to make it findable.
|
||||||
const bad=c=>c.failing?.reason||c.last_error;
|
const bad=c=>c.failing?.reason||c.last_error;
|
||||||
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
|
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
|
||||||
|
const nbad=mine.filter(bad).length;
|
||||||
const el=document.createElement('div');
|
const el=document.createElement('div');
|
||||||
|
// A feed that has failed for a day goes grey, as if switched off: a change in lightness
|
||||||
|
// shows in every theme, where the red mark alone was easy to miss on a dark one.
|
||||||
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'')+
|
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'')+
|
||||||
|
(err?' err':'')+(f.failing?' failing':'')+
|
||||||
(f.pinned&&!depth?' pinned':'')+(f===lastPin&&lastTop>=0?' lastpin':'');
|
(f.pinned&&!depth?' pinned':'')+(f===lastPin&&lastTop>=0?' lastpin':'');
|
||||||
el.tabIndex=0; el.dataset.id=f.id;
|
el.tabIndex=0; el.dataset.id=f.id;
|
||||||
const open = !!(kids && (expanded.has(f.id) || q));
|
const open = !!(kids && (expanded.has(f.id) || q));
|
||||||
el.innerHTML =
|
el.innerHTML =
|
||||||
// The error mark hangs in the margin where a folder's triangle does. A folder already has
|
(kids?`<button class="chev${err?' bad':''}" aria-expanded="${open}" title="${err?`A feed inside has a problem: ${esc(err)}`:'Show or hide the feeds inside'}" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`:'')+
|
||||||
// its triangle there, so that turns red instead, and the feed inside shows the mark.
|
// The mark sits on the artwork, the thing the eye scans the list by, and the line under
|
||||||
(kids?`<button class="chev${err?' bad':''}" aria-expanded="${open}" title="${err?`A feed inside has a problem: ${esc(err)}`:'Show or hide the feeds inside'}" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`
|
// the name says what is wrong in place of the counts.
|
||||||
:err?`<span class="ferr" role="img" title="${esc(err)}" aria-label="Error: ${esc(err)}">${ICON.alert}</span>`:'')+
|
`<span class="fart">${mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id)}`+
|
||||||
(mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id))+
|
(err?`<span class="ferr" role="img" title="${esc(err)}" aria-label="Error: ${esc(err)}">${ICON.alert}</span>`:'')+
|
||||||
`<div class="txt"><b>${f.pinned?`<span class="fpin" title="Pinned">${ICON.pinOn}</span>`:''}${esc(f.title||f.id)}</b><small>`+
|
(f.pinned?`<span class="fpin" role="img" title="Pinned" aria-label="Pinned">${ICON.pinOn}</span>`:'')+`</span>`+
|
||||||
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
|
`<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+
|
||||||
|
(nbad?`${plural(nbad,'feed')} not updating`
|
||||||
|
:err?esc(f.failing?.reason||'The last check failed')
|
||||||
|
:`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`)+
|
||||||
`</small></div>`+
|
`</small></div>`+
|
||||||
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
(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>`;
|
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
||||||
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
||||||
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
if(kids) $('.chev',el).onclick=ev=>{ 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);
|
list.appendChild(el);
|
||||||
}
|
}
|
||||||
paintScanning();
|
paintScanning();
|
||||||
|
|||||||
@@ -36,13 +36,25 @@ function pullShow(dy: number){
|
|||||||
tip.textContent = dy >= PULL ? 'Release to check for new items' : 'Pull to check for new items';
|
tip.textContent = dy >= PULL ? 'Release to check for new items' : 'Pull to check for new items';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A check started by a pull, while its spinner is up. Letting go showed nothing before (issue
|
||||||
|
/// #68), the sidebar's spinner is hidden on a phone, and people pulled again and again.
|
||||||
|
let refreshing = false;
|
||||||
|
|
||||||
function refreshFeed(){
|
function refreshFeed(){
|
||||||
const f = S.feeds.find(x => x.id === S.feed);
|
const f = S.feeds.find(x => x.id === S.feed);
|
||||||
if(!f && S.feed !== ':all') return;
|
if(!f && S.feed !== ':all') return;
|
||||||
|
if(refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
|
// Outside #list, which a feed's render rebuilds and would take the spinner with it.
|
||||||
|
const spin = document.createElement('div');
|
||||||
|
spin.id = 'pullspin'; spin.setAttribute('role', 'status'); spin.textContent = 'Checking for new items';
|
||||||
|
document.body.append(spin);
|
||||||
// New items arrive by the event stream when the scan finishes, as they do for a button press.
|
// New items arrive by the event stream when the scan finishes, as they do for a button press.
|
||||||
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
|
const asked = api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
|
||||||
.catch(e => toast(e.message, true));
|
.catch(e => toast(e.message, true));
|
||||||
loadEntries();
|
loadEntries();
|
||||||
|
// Up for two seconds at least, so it is seen even when the server answers at once.
|
||||||
|
Promise.all([asked, new Promise(r => setTimeout(r, 2000))]).then(() => { spin.remove(); refreshing = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('touchstart', ev => {
|
document.addEventListener('touchstart', ev => {
|
||||||
@@ -83,7 +95,7 @@ document.addEventListener('touchmove', ev => {
|
|||||||
}, {passive: false});
|
}, {passive: false});
|
||||||
|
|
||||||
/// On a phone, what lies beside the reader while it is dragged by dx: the item it will move on
|
/// On a phone, what lies beside the reader while it is dragged by dx: the item it will move on
|
||||||
/// to, a note that there are no more, or, going back to the list, a dimmer over the list. The
|
/// to, the accent past the last one, or, going back to the list, a dimmer over the list. The
|
||||||
/// reader sits over the list there, and moving it alone showed the list through the gap it left
|
/// reader sits over the list there, and moving it alone showed the list through the gap it left
|
||||||
/// (issue #52). Kept outside #content, which every feed render rebuilds, and inert, since its
|
/// (issue #52). Kept outside #content, which every feed render rebuilds, and inert, since its
|
||||||
/// copy of the reader's markup repeats the reader's ids.
|
/// copy of the reader's markup repeats the reader's ids.
|
||||||
@@ -97,7 +109,7 @@ function beside(dx: number){
|
|||||||
}
|
}
|
||||||
if(p.dataset.key !== kind + (e?.guid || '')){
|
if(p.dataset.key !== kind + (e?.guid || '')){
|
||||||
p.dataset.key = kind + (e?.guid || ''); p.className = kind;
|
p.dataset.key = kind + (e?.guid || ''); p.className = kind;
|
||||||
p.innerHTML = e ? `<div>${detailHtml(e)}</div>` : kind === 'end' ? '<p>No more items</p>' : '';
|
p.innerHTML = e ? `<div>${detailHtml(e)}</div>` : '';
|
||||||
}
|
}
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|||||||