Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bad68670a | |||
| d87d024096 | |||
| 44afc08b9d | |||
| a21472730e | |||
| 995ee05557 | |||
| 7af6153d18 | |||
| c1d8983ca1 | |||
| 4c1b78408f | |||
| d850c02501 | |||
| 363a5a122d | |||
| 3d9b45ddd7 | |||
| b54b47897b | |||
| b89c371662 | |||
| 6daab6989e | |||
| 187729a278 | |||
| 3df7c974e8 | |||
| 59aa00e07c | |||
| b4c0048f68 | |||
| d5ffe5d042 | |||
| d16460267f | |||
| 21c786f48a | |||
| 3a8d564eb7 | |||
| f9246129f2 | |||
| 5cf7a28c44 | |||
| ac3808fe97 | |||
| 341d880e13 | |||
| cb4407b19d | |||
| 7f61250852 | |||
| c90a66a24f | |||
| 692a510217 | |||
| 2c4ba7896e | |||
| 36a820f283 | |||
| 41fc2e3933 | |||
| 4eba746ba4 | |||
| ce78599ab4 | |||
| 5130f754cc | |||
| 2077ea5810 | |||
| 026fbd1622 | |||
| 1aef6ba421 | |||
| 9a41641eaa | |||
| 33eb1f4db2 | |||
| 3b48f51a56 | |||
| 55b0192df5 | |||
| e07d27d9f2 | |||
| 3532710b3d | |||
| b56c97259d | |||
| 1025416181 | |||
| 894e5e4711 | |||
| 0e21195e8d | |||
| 3b41abd860 | |||
| 47ad68c7da | |||
| ae58e4653a | |||
| dc3ee016db | |||
| f916ca972a | |||
| 7627af5bc8 | |||
| fbe7629420 | |||
| e882904c11 | |||
| f1a156c974 | |||
| f34423735a | |||
| 15bdce486d | |||
| 0c2c3a379e | |||
| 3e4d67fd2f | |||
| 2ed8781604 | |||
| d0ed953e84 | |||
| 4f1e7e23f6 | |||
| b18c92d1f5 | |||
| 9e5dcbb8c4 | |||
| ce485a82ac | |||
| f8371f255d | |||
| 1ae91170fd | |||
| 20f53183ac | |||
| 8d93b58deb | |||
| 0fdd609791 | |||
| 13cad18857 | |||
| 70990c70c7 | |||
| 30622fd3e1 | |||
| 1949f3e493 | |||
| 3d94bce4f5 | |||
| cd7a9b8530 | |||
| ca73b8136f | |||
| 6fde4b73cd | |||
| cdf2c98f9f | |||
| 6640ca9612 | |||
| 62a3241139 | |||
| 8f19d88fc9 | |||
| dbfe7918ac |
@@ -5,5 +5,18 @@
|
|||||||
"Bash(env)",
|
"Bash(env)",
|
||||||
"mcp__outline__read_document"
|
"mcp__outline__read_document"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Edit|Write|MultiEdit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.rs) rustfmt --edition 2024 \"$f\" 2>/dev/null || true;; esac"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Q=.claude/skills/ipx-prod-check/query.py
|
|||||||
$Q logs '<LogQL>' [since] # lines, newest first (500 at most)
|
$Q logs '<LogQL>' [since] # lines, newest first (500 at most)
|
||||||
$Q metric '<LogQL metric>' [since] # $range becomes `since`
|
$Q metric '<LogQL metric>' [since] # $range becomes `since`
|
||||||
$Q traces '<TraceQL>' [since] # slowest first
|
$Q traces '<TraceQL>' [since] # slowest first
|
||||||
|
$Q spans '<TraceQL>' [since] # the matching spans, each with its own time, slowest first
|
||||||
$Q trace <trace id> # one trace as a tree, with its log lines
|
$Q trace <trace id> # one trace as a tree, with its log lines
|
||||||
$Q prom '<PromQL>' [since] # Prometheus, now; $range becomes `since`
|
$Q prom '<PromQL>' [since] # Prometheus, now; $range becomes `since`
|
||||||
```
|
```
|
||||||
@@ -63,7 +64,7 @@ lines had no trace id, the access log's time was `ms`, and each event was logged
|
|||||||
| `ipx::scan` | `ev` and the event's own: `feed`, `new`, `downloaded`, `failed`, `bytes`, `msg`, `url`, `feeds`, `reason`, `from`, `to`; on a failure `error.type` and, from an HTTP error, `http.response.status_code` | the daemon's events, one line each, in words; warnings are feed and download failures |
|
| `ipx::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::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` | message, sometimes fields | start-up, shutdown, account and config messages |
|
||||||
| `ipx::client` | `ev`, `user`, `feed`, `enclosure`, `deadair`; `msg`, `src`, `line` on an error; `ms` on a timing | what the page reports from the browser (#190): `page_error`, `rejection` and `player_error` as warnings; `play_start` (ms from play to sound) and `stall` (ms waiting mid-play) as info. Lines carry the trace of the action in the page: its API requests send `traceparent`, one trace id per tap or key |
|
| `ipx::client` | `ev`, `user`, `feed`, `enclosure`, `deadair`; `msg`, `src`, `line` on an error; `ms` on a timing | what the page reports from the browser (#190): `page_error`, `rejection` and `player_error` as warnings; `play_start` (ms from play to sound), `stall` (ms waiting mid-play) and `list_shown` (ms from a click to its list drawn, with `feed` the view and `filter`, #246) as info. Lines carry the trace of the action in the page: its API requests send `traceparent`, one trace id per tap or key |
|
||||||
|
|
||||||
Events (`ev`): `feed_start`, `feed_done` (new, downloaded, failed, torrents), `feed_skip` (not due,
|
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
|
routine), `feed_error` (msg), `feed_moved` (from, to: a permanent redirect followed, the address
|
||||||
@@ -109,6 +110,10 @@ from 2026-10-11 15:15 UTC. All gauges:
|
|||||||
| `ipx_feed_retries` | feeds waiting to retry a failed name lookup |
|
| `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_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_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_signals_judged_total{user,key}`, `ipx_signals_hits_total{user,key}` | counters: items Jev judged for Signals, and Signals they fit, by person and whose key paid (`server` or `own`, #278) (#254) |
|
||||||
|
| `ipx_jev_requests_total{result="ok"\|"key_refused"\|"malformed"\|"rate_limited"\|"error"}`, `ipx_jev_seconds_total`, `ipx_jev_paced_seconds_total` | counters: calls to TypeSafe's Jev, Signals' and icons', by how they ended; their seconds, the wait for a turn included; and that wait alone. `malformed` is ipx's request at fault (#257); `rate_limited` is a 429 from TypeSafe, whose limits are 80 calls and 100,000 input tokens a second, which ipx paces itself under at 60 and 80,000 (#277) |
|
||||||
|
| `ipx_jev_tokens_total{kind="input"\|"output",user,key}` | counter: tokens TypeSafe counted, as its answers say, by person and whose key paid: what Signals, icon picks and key checks cost (#275, #278). A call shared on the server's key is split by each person's share of its questions |
|
||||||
|
| `ipx_signals_last_pass_age_seconds`, `ipx_signals_keys_refused` | since the Signals judge last finished a pass (at most 5 minutes when well; absent until one has); people whose key TypeSafe refused |
|
||||||
| `directory_process_resident_bytes`, `directory_process_open_fds`, `directory_feed_fetches_total`, `directory_feed_bytes_total`, `directory_uptime_seconds`, `directory_build_info` | the directory's, `job="ipx-directory"`, from its `metrics_bind` (production `192.168.1.130:9188`, LAN only) |
|
| `directory_process_resident_bytes`, `directory_process_open_fds`, `directory_feed_fetches_total`, `directory_feed_bytes_total`, `directory_uptime_seconds`, `directory_build_info` | the directory's, `job="ipx-directory"`, from its `metrics_bind` (production `192.168.1.130:9188`, LAN only) |
|
||||||
| `ipx_feeds`, `ipx_downloads_pending`, `ipx_files_downloaded` | the library, as the healthcheck last counted it (every 30 s) |
|
| `ipx_feeds`, `ipx_downloads_pending`, `ipx_files_downloaded` | the library, as the healthcheck last counted it (every 30 s) |
|
||||||
|
|
||||||
@@ -155,18 +160,32 @@ a count says something happened, the lines and traces say why.
|
|||||||
`feeds` above 0; none at all for over 10 minutes means the worker is stuck. And `pending` is
|
`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
|
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.
|
`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).
|
5. **Slow and failed traces.** Look at ipx's requests as spans, not traces: a trace started by a
|
||||||
|
tap in the page also holds that page's live updates and any audio it streamed, minutes long,
|
||||||
|
so a list of traces over a few seconds is nothing but those. `spans` lists each matching span
|
||||||
|
with its own time and route, slowest first:
|
||||||
```
|
```
|
||||||
$Q traces '{resource.deployment.environment.name="production" && duration > 5s}'
|
R='resource.deployment.environment.name="production" && resource.service.name="ipx"'
|
||||||
|
$Q spans "{$R && span.http.route != \"\" && span.http.route != \"/api/events\" && span.http.route !~ \"/media/.*\" && duration > 1s} | select(span.http.route)"
|
||||||
|
$Q spans "{$R && name=\"step\" && duration > 500ms} | select(span.of)"
|
||||||
$Q traces '{resource.deployment.environment.name="production" && status = error}'
|
$Q traces '{resource.deployment.environment.name="production" && status = error}'
|
||||||
$Q trace <id>
|
$Q trace <id>
|
||||||
```
|
```
|
||||||
Scans (`scan`) are long by nature, since they fetch many feeds one after another: look for one
|
Group the first by hour and by route (`awk '{print substr($3,1,2), $NF}' | sort | uniq -c`),
|
||||||
`feed` or `fetch` span holding most of it, or a `download` far slower than its size explains.
|
then lay the hours beside Tower's (the Tower section): load over 100 with processes blocked
|
||||||
A web request over a second is worth a look; the trace shows whether the time is in the
|
and memory under 4 GB is the host stalling, and every request of those minutes is slow with
|
||||||
handler or a scan it waited on.
|
it, ipx's queries included. On 2026-10-18 that was 120 of 130 requests over a second, up to 43 s.
|
||||||
|
What is left at quiet hours is ipx's own: open its trace and see which step holds the time. A
|
||||||
|
request far longer than its steps added up waited outside them, for a connection or a thread.
|
||||||
|
The second line finds steps over half a second wherever they ran; a step slow only once, first
|
||||||
|
thing after hours idle, is Postgres reading from disk what it no longer had in memory (All
|
||||||
|
Subscriptions' `entries_in`, a second where it is 60 ms warm).
|
||||||
|
|
||||||
Steps are `step` spans named by `of` (#187): an item list's `entries_in`, `sanitize`,
|
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 (`$Q spans '{... && name="feed" && duration > 10s}'`),
|
||||||
|
or a `download` far slower than its size explains.
|
||||||
|
|
||||||
|
Steps are `step` spans named by `of` (#187): an item list's `entries_in` (with the list asked for: `filter`, `sort`, `dir`, `offset`, `limit`, `search`), `sanitize`,
|
||||||
`deadair`, `count_in`; the feed list's `catalogue`, `feed_list` and its queries; a `reap`'s
|
`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
|
`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`
|
since #193, so a reap is about a second), `trim_artwork`, with what it removed on the `reap`
|
||||||
@@ -194,10 +213,12 @@ a count says something happened, the lines and traces say why.
|
|||||||
$Q prom 'max(max_over_time(ipx_http_requests_in_flight{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 'sum(increase(ipx_events_missed_total{job="ipx"}[$range]))' 24h
|
||||||
$Q prom 'max(max_over_time(ipx_deadair_busy_seconds{job="ipx"}[$range]))' 24h
|
$Q prom 'max(max_over_time(ipx_deadair_busy_seconds{job="ipx"}[$range]))' 24h
|
||||||
|
$Q prom 'sum by (result) (increase(ipx_jev_requests_total{job="ipx"}[$range]))' 24h
|
||||||
|
$Q prom 'max(max_over_time(ipx_signals_last_pass_age_seconds{job="ipx"}[$range]))' 24h
|
||||||
```
|
```
|
||||||
`up` 0 means Prometheus cannot reach the port: the container is down or `metrics_bind` is
|
`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
|
unset. The daemon keeps all 20 connections open on Postgres (#241), so `idle` is the pool's 20 less
|
||||||
one: find the slow requests or scan of that time in check 3 and 5. A worker queue that only
|
those in use; in use at 20, idle at 0, means something is waiting for one: find the slow requests or scan of that time in check 3 and 5. A worker queue that only
|
||||||
grows means downloads are not keeping up, or the worker is stuck on one job. A runtime queue
|
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
|
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
|
thread or burning one. Memory, open files or tasks only climbing across a day with no restart
|
||||||
@@ -210,16 +231,20 @@ a count says something happened, the lines and traces say why.
|
|||||||
where the worker's time went (reap was most of it until #193). Web requests in flight near
|
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
|
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.
|
counts until reloaded. DeadAir over a minute on one file is a file ffmpeg chokes on.
|
||||||
|
Any `rate_limited` Jev call means the pacing is not holding ipx under TypeSafe's limits (#277); each
|
||||||
|
`jev` span has `paced_ms`, its wait for a turn. Any `malformed` Jev call is ipx asking wrongly (#257); `key_refused` is someone's key; the Signals
|
||||||
|
judge over 10 minutes since a pass is stuck on Jev: its `signals` trace shows which `jev` call.
|
||||||
|
|
||||||
7. **What the page saw.** The browser's own errors and timings, which no server line shows.
|
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 metric 'sum by (ev, msg) (count_over_time({container="iPX"} |= "\"target\":\"ipx::client\"" | json | __error__="" | level="WARN" [$range]))' 7d
|
||||||
$Q logs '{container="iPX"} |= "\"target\":\"ipx::client\"" | json | ev=~"play_start|stall" | line_format "{{.timestamp}} {{.user}} {{.ev}} {{.ms}}ms deadair={{.deadair}} {{.feed}}"' 24h
|
$Q logs '{container="iPX"} |= "\"target\":\"ipx::client\"" | json | ev=~"play_start|stall" | line_format "{{.timestamp}} {{.user}} {{.ev}} {{.ms}}ms deadair={{.deadair}} {{.feed}}"' 24h
|
||||||
|
$Q metric 'quantile_over_time(0.95, {container="iPX"} |= "\"ev\":\"list_shown\"" | json | __error__="" | unwrap ms [$range]) by (feed, filter)' 24h
|
||||||
```
|
```
|
||||||
A `player_error` is playback that failed in front of someone: `hls.js: ...` is DeadAir's
|
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
|
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
|
cannot play. A `page_error` or `rejection` is a bug in the page; `src` and `line` point into
|
||||||
the minified `app.js`, so match the message to `web/src`. `play_start` over 2000 ms, or a
|
the minified `app.js`, so match the message to `web/src`. A `list_shown` over 1000 ms is a list someone waited for: its trace says whether the API or the network held it. `play_start` over 2000 ms, or a
|
||||||
`stall`, is worth a trace: was it the server, Cloudflare, or the network.
|
`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.
|
`docker logs --since 10m iPX 2>&1 | tail -5` shows the newest lines when Loki is behind.
|
||||||
@@ -252,7 +277,7 @@ to see where the time went, Caddy's span with ipx's inside it. A slow page with
|
|||||||
Caddy or the network; a long one is ipx.
|
Caddy or the network; a long one is ipx.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$Q traces '{resource.service.name="ipx-web" && duration > 2s}'
|
$Q spans '{resource.service.name="ipx-web" && span.url.path != "/api/events" && span.url.path !~ "/media/.*" && duration > 2s} | select(span.url.path)'
|
||||||
$Q traces '{resource.service.name="ipx-web" && status = error}'
|
$Q traces '{resource.service.name="ipx-web" && status = error}'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -302,11 +327,15 @@ then; a steady stream is worth an issue, since Cloudflare can rate-limit it. A s
|
|||||||
`refused_private` is not 0 means an ipx server tried to send a feed that looks private: find which
|
`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
|
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
|
`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
|
checked every twelve hours (#243), 25 at a time, so `feeds checked` over a day should be about
|
||||||
the feeds listed; none for over six hours means the refresher is stuck. `could not store a feed
|
twice the feeds listed; none for over twelve hours means the refresher is stuck. `could not store a feed
|
||||||
check` or `could not read which feeds are due` is its database (`ipx_directory` on the
|
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
|
`databases` project's Postgres). A config that does not load (`config changed but does not
|
||||||
load`) keeps the old tokens: worth fixing before someone's new token is needed.
|
load`) keeps the old tokens: worth fixing before someone's new token is needed. Weekly, `Apple
|
||||||
|
Podcasts synced` (`shows`, `accepted`) brings Apple's charts in as the `apple-podcasts` snapshot
|
||||||
|
(#252); `Apple Podcasts' sync failed` retries in an hour, worth an issue when it keeps failing, and
|
||||||
|
`Apple's podcast categories are not ipx's` (`added`, `gone`) is an issue to update `CATEGORIES` in
|
||||||
|
`crates/feed`.
|
||||||
|
|
||||||
## Tower
|
## Tower
|
||||||
|
|
||||||
@@ -349,8 +378,9 @@ container after a deploy.
|
|||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
`docker logs` and `docker exec iPX ipx ...` are fine. Do not query production's Postgres
|
`docker logs` and `docker exec iPX ipx ...` are fine. On production's Postgres, `EXPLAIN (ANALYZE,
|
||||||
directly: ask the user if a question needs the database.
|
BUFFERS)` of any query is allowed, read only, as CLAUDE.md's "Production's database" shows; any
|
||||||
|
other query, ask the user first.
|
||||||
|
|
||||||
## What to do with what you find
|
## What to do with what you find
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
query.py logs '<LogQL log query>' [since] lines, newest first
|
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 metric '<LogQL metric query>' [since] one value per series; $range is `since`
|
||||||
query.py traces '<TraceQL query>' [since] matching traces, slowest first
|
query.py traces '<TraceQL query>' [since] matching traces, slowest first
|
||||||
|
query.py spans '<TraceQL query>' [since] the matching spans, each with its own time
|
||||||
query.py trace <trace id> one trace's spans, as a tree
|
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`
|
query.py prom '<PromQL query>' [since] one value per series, now; $range is `since`
|
||||||
|
|
||||||
@@ -58,6 +59,21 @@ def main():
|
|||||||
name = t.get("rootTraceName") or "(from the page)"
|
name = t.get("rootTraceName") or "(from the page)"
|
||||||
print(f"{t.get('durationMs', 0):>8}ms {when}Z {t['traceID']} {name}")
|
print(f"{t.get('durationMs', 0):>8}ms {when}Z {t['traceID']} {name}")
|
||||||
print(f"-- {len(traces)} trace(s)", file=sys.stderr)
|
print(f"-- {len(traces)} trace(s)", file=sys.stderr)
|
||||||
|
elif kind == "spans":
|
||||||
|
# The spans that matched, each with its own time: a trace's length says nothing about a
|
||||||
|
# slow request in it when the trace also holds a page's live updates or an audio stream.
|
||||||
|
r = fetch(f"http://tempo:3200/api/search?q={enc(q)}&start={int(start)}&end={int(now)}&limit=200&spss=20")
|
||||||
|
rows = []
|
||||||
|
for t in r.get("traces", []):
|
||||||
|
for ss in t.get("spanSets") or [t.get("spanSet") or {}]:
|
||||||
|
for sp in ss.get("spans", []):
|
||||||
|
attrs = {a["key"]: next(iter(a["value"].values()), None) for a in sp.get("attributes", [])}
|
||||||
|
what = attrs.get("http.route") or attrs.get("of") or attrs.get("url.path") or sp.get("name") or ""
|
||||||
|
rows.append((int(sp.get("durationNanos", 0)) / 1e6, int(sp["startTimeUnixNano"]), t["traceID"], what))
|
||||||
|
for ms, ts, tid, what in sorted(rows, reverse=True):
|
||||||
|
when = time.strftime("%m-%d %H:%M:%S", time.gmtime(ts / 1e9))
|
||||||
|
print(f"{ms:>8.0f}ms {when}Z {tid} {what}")
|
||||||
|
print(f"-- {len(rows)} span(s)", file=sys.stderr)
|
||||||
elif kind == "prom":
|
elif kind == "prom":
|
||||||
# ipx's /metrics (#194), scraped as job ipx; through the network like the others.
|
# 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}")
|
r = fetch(f"http://prometheus:9090/api/v1/query?query={enc(q.replace('$range', since))}&time={now}")
|
||||||
|
|||||||
3
.git-blame-ignore-revs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Formatting only: `git config blame.ignoreRevsFile .git-blame-ignore-revs` makes blame skip these.
|
||||||
|
# rustfmt adopted
|
||||||
|
692a5102179b958b5d15cecc4f5e6f16b2955b76
|
||||||
26
.githooks/pre-commit
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Refuses a commit whose Rust is not as rustfmt writes it, or that clippy warns about. Clippy only
|
||||||
|
# checks and never links, so it is light beside a test build (CLAUDE.md, Tests). It checks the
|
||||||
|
# crates the commit touches and those built on them, not the workspace: ipx uses ipx-directory,
|
||||||
|
# and both use ipx-feed. A commit to the page alone runs nothing here.
|
||||||
|
staged=$(git diff --cached --name-only --diff-filter=ACMD)
|
||||||
|
echo "$staged" | grep -q -e '\.rs$' -e 'Cargo\.toml$' -e '^Cargo\.lock$' || exit 0
|
||||||
|
if ! cargo fmt --all --check >/dev/null 2>&1; then
|
||||||
|
echo "pre-commit: the Rust is not formatted; run cargo fmt --all" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if echo "$staged" | grep -q -e '^Cargo\.toml$' -e '^Cargo\.lock$' -e '^crates/feed/'; then
|
||||||
|
pkgs="ipx-feed ipx-directory ipx"
|
||||||
|
elif echo "$staged" | grep -q '^crates/directory/'; then
|
||||||
|
pkgs="ipx-directory ipx"
|
||||||
|
elif echo "$staged" | grep -q '^crates/ipx/'; then
|
||||||
|
pkgs="ipx"
|
||||||
|
else
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
args=$(for p in $pkgs; do printf ' -p %s' "$p"; done)
|
||||||
|
# shellcheck disable=SC2086 # one word a flag
|
||||||
|
if ! cargo clippy $args --all-targets -q -- -D warnings; then
|
||||||
|
echo "pre-commit: clippy has warnings in $pkgs; fix them, or allow one where it is wrong, with a comment saying why" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
71
CLAUDE.md
@@ -19,7 +19,7 @@ service of the `content` project, beside audiobookshelf, immich and Plex (#208).
|
|||||||
| Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipx.env` beside the compose file (`/mnt/fast/arcane/projects/ipx/ipx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
|
| 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` |
|
||||||
| Directory | `ipx-directory` in the same project, `192.168.1.130:8100`, public at `directory.ipxreader.app` through the tunnel with no Access in front: its pages are anyone's to browse, read only (#215), and its API (`/v1`) takes a token for everything. Until 2026-10-12 it was `ipx-dir.sdf1.net` (#214). Config `/mnt/fast/appdata/ipx-directory/directory.toml` (token hashes; Tower's own token in `tower.token` beside it, and entered in ipx's Admin, Directory, which keeps it in the database's `settings`), database `ipx_directory` on the same Postgres, URL in `directory.env` beside the compose file. See [docs/directory.md](docs/directory.md) | `/config/directory.toml` |
|
| Directory | `ipx-directory` in the same project, `192.168.1.130:8100`, public at `directory.ipxreader.app` through the tunnel with no Access in front: its pages are anyone's to browse, read only (#215), and its API (`/v1`) takes a token for everything. Until 2026-10-12 it was `ipx-dir.sdf1.net` (#214). Config `/mnt/fast/appdata/ipx-directory/directory.toml` (token hashes; Tower's own token in `tower.token` beside it, and entered in ipx's Admin, Directory, which keeps it in the database's `settings`, with the address ipx reaches it at: `http://ipx-directory:8100`, inside Docker, not the public address, which went out through Cloudflare and the tunnel, 170-340 ms a call where the directory answers in 16 ms), database `ipx_directory` on the same Postgres, URL in `directory.env` beside the compose file. See [docs/directory.md](docs/directory.md) | `/config/directory.toml` |
|
||||||
| Home page | `ipxreader.app` (and `www`): a static page from `site/`, on the Cloudflare Pages project `ipxreader`, not on Tower. See below | |
|
| 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` |
|
| 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`) |
|
| 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`) |
|
||||||
@@ -86,6 +86,11 @@ Testing sign-in from Tower itself proves nothing about the tunnel: Tower's own r
|
|||||||
published port as `192.168.1.130`, and Caddy rightly strips their `Cf-Access-*`. Send them from a
|
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).
|
container on another network, as cloudflared's are (#209).
|
||||||
|
|
||||||
|
The three containers resolve names through Cloudflare (`dns: [1.1.1.1, 1.0.0.1]` in the compose
|
||||||
|
file), not the router: the LAN's DNS is NextDNS, which answers for a domain it filters with its own
|
||||||
|
block page, and ipx then logged a feed as failing with `invalid peer certificate: UnknownIssuer`
|
||||||
|
(tomrenner.com, 2026-10-17). Docker's own DNS still answers for the services' names first.
|
||||||
|
|
||||||
**Name the service.** A bare `up -d` recreates every container in the project, which once the
|
**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.
|
split lands means the frontend and directory too. Run `pull` before `up`, because `up` reuses whatever `latest` the host already has.
|
||||||
|
|
||||||
@@ -171,16 +176,57 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
|
cargo fmt --all --check # rustfmt, as rustfmt.toml says; the pre-commit hook refuses a commit without it
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings # no warnings, ever
|
||||||
|
cargo deny check # known vulnerabilities, licences, sources (deny.toml); reads Cargo.lock, builds nothing
|
||||||
|
cargo machete # dependencies a crate lists and does not use; now and then, it can be wrong
|
||||||
|
cargo test # parsing, filters, retention, schedules, SQL, per-user state
|
||||||
npx tsc -p . # type-checks web/src
|
npx tsc -p . # type-checks web/src
|
||||||
node tests/page-smoke.js
|
node tests/page-smoke.js
|
||||||
node --test tests/page-unit.js # the page's own functions, called directly, in a second
|
node --test tests/page-unit.js # the page's own functions, called directly, in a second
|
||||||
node tests/native-bridge.js # the page hands playback to a native shell
|
node tests/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 # 66 browser tests against a real daemon on fixture feeds
|
npx playwright test # the 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
|
node tests/load/run.js # k6: many people at once against a scratch daemon with 1,500 feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Builds share Tower with everything else, production included.** On 2026-10-16 a build of the
|
||||||
|
directory followed in the same command by its tests' compile took Tower's load past 200 and the
|
||||||
|
session was stopped. So:
|
||||||
|
|
||||||
|
* One cargo command at a time: a build, then the tests, as separate commands, never chained or
|
||||||
|
run in the background beside another, and never two crates' builds at once. Do not tune `-j`.
|
||||||
|
* Look at the load before and after each (`cat /proc/loadavg`). Above about 24, Tower's twelve
|
||||||
|
threads twice over, wait for it to come down before starting another.
|
||||||
|
* Test only what the change is in. ipx uses ipx-directory, and both use ipx-feed, so:
|
||||||
|
|
||||||
|
| changed | run |
|
||||||
|
|---|---|
|
||||||
|
| `web/` alone | the three page checks; `npx playwright test -g '<what changed>'`; no cargo |
|
||||||
|
| `crates/ipx/` | `cargo test -p ipx`; the browser tests too if the API changed |
|
||||||
|
| `crates/directory/` | `cargo test -p ipx-directory`, then `-p ipx` |
|
||||||
|
| `crates/feed/` | `cargo test -p ipx-feed`, then `-p ipx-directory`, then `-p ipx` |
|
||||||
|
| `Cargo.toml`, `Cargo.lock` | all three, one at a time |
|
||||||
|
|
||||||
|
The pre-commit hook's clippy follows the same table. The whole browser suite once, at the end
|
||||||
|
of a change to the page or the API; never `cargo test` for the workspace in one command.
|
||||||
|
* Builds and test binaries live under `target/`, a link to `/mnt/fast`; linking through
|
||||||
|
`/mnt/user` (FUSE) once took the load to 248 on its own.
|
||||||
|
* **Memory is the tighter limit.** Tower has 31 GB, no swap, and the containers leave about 4-7
|
||||||
|
GB. A build past that stalls everything on page faults: load 160 with the CPU idle and the
|
||||||
|
disks quiet, many processes blocked (`node_procs_blocked`, `node_vmstat_pgmajfault`). On
|
||||||
|
2026-10-18 linking a 431 MB test binary did it for 54 minutes. So `[profile.dev]` keeps only
|
||||||
|
line tables and dependencies no debug info; keep it that way, and look at `MemAvailable` beside
|
||||||
|
the load.
|
||||||
|
|
||||||
|
The Rust is formatted by rustfmt (`rustfmt.toml`) and kept free of clippy's warnings. Claude Code
|
||||||
|
formats each `.rs` file it edits (a hook in `.claude/settings.json`), and the pre-commit hook refuses
|
||||||
|
a commit touching Rust that is unformatted or has a clippy warning. A clone turns on the hook once: `git config core.hooksPath .githooks`; and `git config blame.ignoreRevsFile
|
||||||
|
.git-blame-ignore-revs` keeps blame past the commit that first formatted everything. A clippy
|
||||||
|
suggestion is not always the same code: on adopting it, `fold(false, |any, id| remove(id) || any)`
|
||||||
|
was offered as `any(...)`, which stops at the first and would have removed one feed of many. A lint
|
||||||
|
that is wrong for a line gets `#[allow(clippy::...)]` there with a comment saying why.
|
||||||
|
|
||||||
Things about the browser suite that have cost time:
|
Things about the browser suite that have cost time:
|
||||||
|
|
||||||
* It starts **its own daemon and database** under `/tmp/ipx-ui-test`, wiped once per run. Playwright
|
* It starts **its own daemon and database** under `/tmp/ipx-ui-test`, wiped once per run. Playwright
|
||||||
@@ -222,6 +268,25 @@ seeking through files, a flood of wrong passwords. Things about them:
|
|||||||
Non-trivial logic leaves one runnable check behind. Pure functions (`merge_policy`, `pick`,
|
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.
|
||||||
|
|
||||||
|
## Production's database
|
||||||
|
|
||||||
|
The owner allows `EXPLAIN (ANALYZE, BUFFERS)` of any query against production's Postgres, to see
|
||||||
|
where a query's time goes; nothing else without asking. Run it read only, so nothing can change:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
U=$(grep -E '^IPX_DATABASE_URL=' /mnt/fast/arcane/projects/ipx/ipx.env | cut -d= -f2- | tr -d '"')
|
||||||
|
psql "$U" -X -v ON_ERROR_STOP=1 <<'SQL'
|
||||||
|
BEGIN READ ONLY;
|
||||||
|
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
|
||||||
|
ROLLBACK;
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
Never print the URL: it carries the password. Write the query as ipx sends it (`$1` parameters
|
||||||
|
become `PREPARE`/`EXECUTE`, or values), for a real account. A query fast here and slow in ipx's
|
||||||
|
trace is time outside the query: on 2026-10-15 the feed list's count took 5 ms in Postgres and 366
|
||||||
|
ms in ipx, the difference being new connections, 80-330 ms each to open (#241).
|
||||||
|
|
||||||
## Things that are true and easy to get wrong
|
## Things that are true and easy to get wrong
|
||||||
|
|
||||||
* **`enclosures.url` is globally UNIQUE.** It is the dedupe key and the reason one file serves every
|
* **`enclosures.url` is globally UNIQUE.** It is the dedupe key and the reason one file serves every
|
||||||
|
|||||||
151
Cargo.lock
generated
@@ -17,6 +17,21 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-no-stdlib"
|
||||||
|
version = "2.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-stdlib"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -25,9 +40,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ammonia"
|
name = "ammonia"
|
||||||
version = "4.1.4"
|
version = "4.2.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
|
checksum = "1f45dfa685e1ec33b1bf1a3b93b8047cbdbef41b6040deebbe6bea7720a45574"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cssparser",
|
"cssparser",
|
||||||
"html5ever",
|
"html5ever",
|
||||||
@@ -445,6 +460,27 @@ dependencies = [
|
|||||||
"syn 3.0.5",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli"
|
||||||
|
version = "8.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
"brotli-decompressor",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli-decompressor"
|
||||||
|
version = "5.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bs58"
|
name = "bs58"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -620,9 +656,12 @@ version = "0.4.41"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8"
|
checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"brotli",
|
||||||
"compression-core",
|
"compression-core",
|
||||||
"flate2",
|
"flate2",
|
||||||
"memchr",
|
"memchr",
|
||||||
|
"zstd",
|
||||||
|
"zstd-safe",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -750,9 +789,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cssparser"
|
name = "cssparser"
|
||||||
version = "0.37.0"
|
version = "0.38.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
|
checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dtoa-short",
|
"dtoa-short",
|
||||||
"itoa",
|
"itoa",
|
||||||
@@ -1398,9 +1437,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.15"
|
version = "0.4.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1516,12 +1555,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.39.0"
|
version = "0.40.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"markup5ever",
|
"markup5ever",
|
||||||
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1820,15 +1860,15 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "1.2.0"
|
version = "1.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
"atom_syndication",
|
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
"flate2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"ipx-directory",
|
"ipx-directory",
|
||||||
"ipx-feed",
|
"ipx-feed",
|
||||||
@@ -1837,9 +1877,7 @@ dependencies = [
|
|||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
"opml",
|
"opml",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"quick-xml 0.42.0",
|
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rss",
|
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1856,12 +1894,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipx-directory"
|
name = "ipx-directory"
|
||||||
version = "1.1.1"
|
version = "1.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"clap",
|
"clap",
|
||||||
|
"flate2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"ipx-feed",
|
"ipx-feed",
|
||||||
"mimalloc",
|
"mimalloc",
|
||||||
@@ -1877,6 +1916,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
"tower",
|
"tower",
|
||||||
|
"tower-http 0.7.1",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-opentelemetry",
|
"tracing-opentelemetry",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -1891,6 +1931,7 @@ dependencies = [
|
|||||||
"atom_syndication",
|
"atom_syndication",
|
||||||
"chrono",
|
"chrono",
|
||||||
"opml",
|
"opml",
|
||||||
|
"pulldown-cmark",
|
||||||
"quick-xml 0.42.0",
|
"quick-xml 0.42.0",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rss",
|
"rss",
|
||||||
@@ -2484,9 +2525,9 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.39.0"
|
version = "0.40.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
|
checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"tendril",
|
"tendril",
|
||||||
@@ -2811,6 +2852,7 @@ version = "0.33.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "699a67345e21962a231955b9059a157e428b219fa5df8efe11f08346fb23a344"
|
checksum = "699a67345e21962a231955b9059a157e428b219fa5df8efe11f08346fb23a344"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"flate2",
|
||||||
"http",
|
"http",
|
||||||
"httpdate",
|
"httpdate",
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
@@ -2942,9 +2984,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf"
|
name = "phf"
|
||||||
version = "0.13.1"
|
version = "0.14.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_shared",
|
"phf_shared",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2952,9 +2994,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_codegen"
|
name = "phf_codegen"
|
||||||
version = "0.13.1"
|
version = "0.14.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_generator",
|
"phf_generator",
|
||||||
"phf_shared",
|
"phf_shared",
|
||||||
@@ -2962,9 +3004,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_generator"
|
name = "phf_generator"
|
||||||
version = "0.13.1"
|
version = "0.14.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"phf_shared",
|
"phf_shared",
|
||||||
@@ -2972,9 +3014,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_shared"
|
name = "phf_shared"
|
||||||
version = "0.13.1"
|
version = "0.14.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"siphasher",
|
"siphasher",
|
||||||
]
|
]
|
||||||
@@ -3087,6 +3129,24 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulldown-cmark"
|
||||||
|
version = "0.13.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"memchr",
|
||||||
|
"pulldown-cmark-escape",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulldown-cmark-escape"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quanta"
|
name = "quanta"
|
||||||
version = "0.12.6"
|
version = "0.12.6"
|
||||||
@@ -3501,9 +3561,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.44"
|
version = "0.23.46"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
|
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -4236,9 +4296,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "string_cache"
|
name = "string_cache"
|
||||||
version = "0.9.0"
|
version = "0.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
|
checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"new_debug_unreachable",
|
"new_debug_unreachable",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
@@ -4248,9 +4308,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "string_cache_codegen"
|
name = "string_cache_codegen"
|
||||||
version = "0.6.1"
|
version = "0.11.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_generator",
|
"phf_generator",
|
||||||
"phf_shared",
|
"phf_shared",
|
||||||
@@ -4651,6 +4711,7 @@ version = "0.7.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-compression",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -5026,9 +5087,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web_atoms"
|
name = "web_atoms"
|
||||||
version = "0.2.6"
|
version = "0.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
|
checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf",
|
"phf",
|
||||||
"phf_codegen",
|
"phf_codegen",
|
||||||
@@ -5458,3 +5519,31 @@ name = "zmij"
|
|||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd"
|
||||||
|
version = "0.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "057cfd910cfac363a0ada849592624b4c9ff2e10bef504c3433810d78ed96f93"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-safe",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-safe"
|
||||||
|
version = "8.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bdd44c6a7284e91f3717755b24315a302edd9153a01f753c3cba3d765e8eafac"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-sys"
|
||||||
|
version = "2.1.1+zstd.1.5.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|||||||
17
Cargo.toml
@@ -15,19 +15,21 @@ anyhow = "1.0.104"
|
|||||||
argon2 = "0.6.0"
|
argon2 = "0.6.0"
|
||||||
atom_syndication = "0.12.10"
|
atom_syndication = "0.12.10"
|
||||||
axum = "0.8.9"
|
axum = "0.8.9"
|
||||||
|
flate2 = "1.1.10"
|
||||||
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
||||||
clap = { version = "4.6.6", features = ["derive"] }
|
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 = { version = "0.33", default-features = false, features = ["trace"] }
|
||||||
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client"] }
|
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client", "gzip-http"] }
|
||||||
opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace"] }
|
opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace"] }
|
||||||
opml = "1.1.6"
|
opml = "1.1.6"
|
||||||
|
pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] }
|
||||||
mimalloc = "0.1.52"
|
mimalloc = "0.1.52"
|
||||||
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"] }
|
||||||
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
|
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "brotli", "zstd", "stream", "json", "charset", "system-proxy"] }
|
||||||
rss = "2.1.1"
|
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"] }
|
||||||
@@ -36,8 +38,17 @@ sha2 = "0.10.9"
|
|||||||
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal", "process"] }
|
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", "compression-br", "compression-gzip", "compression-zstd", "decompression-gzip"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-opentelemetry = { version = "0.34", default-features = false }
|
tracing-opentelemetry = { version = "0.34", default-features = false }
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
|
||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
|
|
||||||
|
# Backtraces keep file and line; the full debug info made a 431 MB test binary whose link ran
|
||||||
|
# Tower, which has no swap, out of memory and stalled it for most of an hour (2026-10-18).
|
||||||
|
[profile.dev]
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
|
# Nobody steps through dependencies.
|
||||||
|
[profile.dev.package."*"]
|
||||||
|
debug = false
|
||||||
|
|||||||
@@ -10,6 +10,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-10-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A feed's items written in Markdown show formatted on its page, from a Markdown description or RSS's `<source:markdown>` (#284).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A listed feed whose address serves something larger than 50 MB is refused as too large, instead of read whole into memory at every check (#295).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- ammonia, which cleans publishers' HTML, updated to 4.2.4 for three advisories, among them XSS through SVG animation tags; rustls to 0.23.46 for five and h2 to 0.4.20 for one (#281).
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-10-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Answers and pages go out compressed, brotli, zstd or gzip as the caller takes them: the feed list ipx asks for was 603 KB of JSON. A snapshot may come gzipped, `/metrics` answers gzipped, and with `OTEL_EXPORTER_OTLP_COMPRESSION=gzip` traces go to the collector compressed (#253).
|
||||||
|
- With `apple_country` set, Apple Podcasts' top charts for that country (the top 100 and the top 50 of each category) come into the directory every week, beside what servers share, without counting as subscribers; and a warning says when Apple's categories no longer match ipx's (#252).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Each feed is checked every twelve hours instead of six (#243).
|
||||||
|
- Feeds are fetched accepting brotli and zstd as well as gzip (#244).
|
||||||
|
- On Postgres the server keeps all ten of its database connections open, so a request does not wait for a new one (#241).
|
||||||
|
|
||||||
## [1.1.1] - 2026-10-15
|
## [1.1.1] - 2026-10-15
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -45,7 +72,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- `ipx-directory`, a directory of public podcasts that ipx servers share: each sends the public feeds its people subscribe to, and any server with a token browses them, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205).
|
- `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).
|
- 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.1.1...main
|
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.3.0...main
|
||||||
|
[1.3.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.2.0...directory-v1.3.0
|
||||||
|
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.1...directory-v1.2.0
|
||||||
[1.1.1]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.0...directory-v1.1.1
|
[1.1.1]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.0...directory-v1.1.1
|
||||||
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.0.0...directory-v1.1.0
|
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.0.0...directory-v1.1.0
|
||||||
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/directory-v1.0.0
|
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/directory-v1.0.0
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx-directory"
|
name = "ipx-directory"
|
||||||
version = "1.1.1"
|
version = "1.3.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
publish = false
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "ipx-directory"
|
name = "ipx-directory"
|
||||||
@@ -26,10 +27,12 @@ serde_json.workspace = true
|
|||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
|
tower-http.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-opentelemetry.workspace = true
|
tracing-opentelemetry.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
flate2.workspace = true
|
||||||
tower.workspace = true
|
tower.workspace = true
|
||||||
|
|||||||
281
crates/directory/src/apple.rs
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
//! Apple Podcasts, weekly (#252): its top charts for a country into the directory, beside what
|
||||||
|
//! ipx servers share, and its categories checked against the copy ipx keeps (`ipx_feed`).
|
||||||
|
//!
|
||||||
|
//! The charts are a snapshot of their own, under `INSTANCE`, with no subscribers: Apple's shows
|
||||||
|
//! are fetched, categorised and listed like any other feed, and the most subscribed stay what this
|
||||||
|
//! directory's people subscribe to. Each show's RSS address comes from Apple's lookup API.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing::Instrument;
|
||||||
|
|
||||||
|
use crate::{Shared, SnapshotEntry};
|
||||||
|
|
||||||
|
/// What the charts are filed under, as a server's snapshot is under its token's name.
|
||||||
|
pub const INSTANCE: &str = "apple-podcasts";
|
||||||
|
/// How often, and how long to wait after a sync fails before trying again.
|
||||||
|
const EVERY: i64 = 7 * 86_400;
|
||||||
|
const RETRY: Duration = Duration::from_secs(3600);
|
||||||
|
/// The top this many overall, and of each category: about a thousand shows once they overlap.
|
||||||
|
const TOP: u32 = 100;
|
||||||
|
const TOP_OF_EACH: u32 = 50;
|
||||||
|
/// Apple asks for no more than about twenty calls a minute.
|
||||||
|
const PAUSE: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// Syncs when a week has passed since the last, forever.
|
||||||
|
pub async fn run(dir: Shared, country: String) {
|
||||||
|
let client = match reqwest::Client::builder()
|
||||||
|
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.connect_timeout(Duration::from_secs(10))
|
||||||
|
.timeout(Duration::from_secs(60))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return tracing::error!(error = %e, "Apple Podcasts' sync could not start"),
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
let last = dir.store.synced_at(INSTANCE).await.unwrap_or(None).unwrap_or(0);
|
||||||
|
let wait = last + EVERY - crate::store::now();
|
||||||
|
if wait > 0 {
|
||||||
|
// Woken hourly at most, so a database restored from before the last sync is noticed.
|
||||||
|
tokio::time::sleep(Duration::from_secs((wait as u64).min(3600))).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let span = tracing::info_span!("apple_sync", country = %country);
|
||||||
|
match sync(&dir, &client, &country).instrument(span).await {
|
||||||
|
Ok(()) => {
|
||||||
|
let _ = dir.store.set_synced(INSTANCE, crate::store::now()).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "Apple Podcasts' sync failed; trying again in an hour");
|
||||||
|
tokio::time::sleep(RETRY).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync(dir: &Shared, client: &reqwest::Client, country: &str) -> Result<()> {
|
||||||
|
let genres: Genres = get(client, "https://itunes.apple.com/WebObjects/MZStoreServices.woa/ws/genres?id=26").await?;
|
||||||
|
let theirs = categories(&genres);
|
||||||
|
let (added, gone) = differ(&theirs, ipx_feed::categories());
|
||||||
|
if !added.is_empty() || !gone.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
added = %added.join(", "), gone = %gone.join(", "),
|
||||||
|
"Apple's podcast categories are not ipx's: the copy in crates/feed (CATEGORIES) wants updating"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The top overall, then the top of each category, each show once.
|
||||||
|
let mut ids: Vec<String> = Vec::new();
|
||||||
|
let charts = std::iter::once((None, TOP)).chain(genre_ids(&genres).into_iter().map(|g| (Some(g), TOP_OF_EACH)));
|
||||||
|
for (genre, n) in charts {
|
||||||
|
let url = match &genre {
|
||||||
|
Some(g) => format!("https://itunes.apple.com/{country}/rss/toppodcasts/limit={n}/genre={g}/json"),
|
||||||
|
None => format!("https://itunes.apple.com/{country}/rss/toppodcasts/limit={n}/json"),
|
||||||
|
};
|
||||||
|
match get::<Chart>(client, &url).await {
|
||||||
|
Ok(chart) => ids.extend(chart_ids(&chart)),
|
||||||
|
// One category's chart missing is no reason to keep last week's whole list.
|
||||||
|
Err(e) => tracing::info!(genre = ?genre, error = %format!("{e:#}"), "an Apple chart did not load"),
|
||||||
|
}
|
||||||
|
tokio::time::sleep(PAUSE).await;
|
||||||
|
}
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
ids.retain(|id| seen.insert(id.clone()));
|
||||||
|
anyhow::ensure!(!ids.is_empty(), "no chart had a show in it");
|
||||||
|
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for batch in ids.chunks(100) {
|
||||||
|
let url = format!("https://itunes.apple.com/lookup?entity=podcast&id={}", batch.join(","));
|
||||||
|
let found: Lookup = get(client, &url).await?;
|
||||||
|
entries.extend(lookup_entries(&found));
|
||||||
|
tokio::time::sleep(PAUSE).await;
|
||||||
|
}
|
||||||
|
let result = dir.store.snapshot(INSTANCE, &entries).await?;
|
||||||
|
tracing::info!(
|
||||||
|
shows = ids.len(),
|
||||||
|
accepted = result.accepted,
|
||||||
|
refused_private = result.refused_private,
|
||||||
|
refused_blocked = result.refused_blocked,
|
||||||
|
refused_invalid = result.refused_invalid,
|
||||||
|
"Apple Podcasts synced"
|
||||||
|
);
|
||||||
|
// Fetched now, not when the refresher next looks.
|
||||||
|
dir.wake.notify_one();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get<T: serde::de::DeserializeOwned>(client: &reqwest::Client, url: &str) -> Result<T> {
|
||||||
|
let span = tracing::info_span!("apple_get", url = %url);
|
||||||
|
async {
|
||||||
|
let resp = client.get(url).send().await.context("asking Apple")?.error_for_status()?;
|
||||||
|
resp.json::<T>().await.context("reading Apple's answer")
|
||||||
|
}
|
||||||
|
.instrument(span)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apple's genres under Podcasts (26): each category's id and name, and its subcategories.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Genres(BTreeMap<String, Genre>);
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Genre {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
subgenres: BTreeMap<String, Genre>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn categories(g: &Genres) -> BTreeMap<String, Vec<String>> {
|
||||||
|
g.0.get("26")
|
||||||
|
.map(|podcasts| {
|
||||||
|
podcasts
|
||||||
|
.subgenres
|
||||||
|
.values()
|
||||||
|
.map(|c| (c.name.clone(), c.subgenres.values().map(|s| s.name.clone()).collect()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn genre_ids(g: &Genres) -> Vec<String> {
|
||||||
|
g.0.get("26").map(|p| p.subgenres.keys().cloned().collect()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What Apple has that ipx's list lacks, and what ipx's has that Apple no longer does, as
|
||||||
|
/// "Category" or "Category / Subcategory".
|
||||||
|
fn differ(theirs: &BTreeMap<String, Vec<String>>, ours: &[(&str, &[&str])]) -> (Vec<String>, Vec<String>) {
|
||||||
|
let flat = |c: &str, subs: &mut dyn Iterator<Item = String>| {
|
||||||
|
std::iter::once(c.to_string()).chain(subs.map(|s| format!("{c} / {s}"))).collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
let theirs: std::collections::BTreeSet<String> =
|
||||||
|
theirs.iter().flat_map(|(c, subs)| flat(c, &mut subs.iter().cloned())).collect();
|
||||||
|
let ours: std::collections::BTreeSet<String> =
|
||||||
|
ours.iter().flat_map(|(c, subs)| flat(c, &mut subs.iter().map(|s| s.to_string()))).collect();
|
||||||
|
(theirs.difference(&ours).cloned().collect(), ours.difference(&theirs).cloned().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A chart, as Apple's iTunes RSS gives it: the shows' Apple ids, in order.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Chart {
|
||||||
|
feed: ChartFeed,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ChartFeed {
|
||||||
|
// A chart of one show is that show, not a list of one.
|
||||||
|
#[serde(default, deserialize_with = "one_or_many")]
|
||||||
|
entry: Vec<ChartEntry>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ChartEntry {
|
||||||
|
id: ChartId,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ChartId {
|
||||||
|
attributes: ChartIdAttributes,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ChartIdAttributes {
|
||||||
|
#[serde(rename = "im:id")]
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn one_or_many<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<ChartEntry>, D::Error> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum OneOrMany {
|
||||||
|
One(ChartEntry),
|
||||||
|
Many(Vec<ChartEntry>),
|
||||||
|
}
|
||||||
|
Ok(match OneOrMany::deserialize(d)? {
|
||||||
|
OneOrMany::One(e) => vec![e],
|
||||||
|
OneOrMany::Many(v) => v,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chart_ids(c: &Chart) -> Vec<String> {
|
||||||
|
c.feed.entry.iter().map(|e| e.id.attributes.id.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apple's lookup answer: each show's RSS address and the category Apple files it under.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Lookup {
|
||||||
|
#[serde(default)]
|
||||||
|
results: Vec<LookupResult>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LookupResult {
|
||||||
|
#[serde(rename = "feedUrl")]
|
||||||
|
feed_url: Option<String>,
|
||||||
|
#[serde(rename = "primaryGenreName")]
|
||||||
|
genre: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup_entries(l: &Lookup) -> Vec<SnapshotEntry> {
|
||||||
|
l.results
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| {
|
||||||
|
let url = r.feed_url.as_deref()?.trim();
|
||||||
|
(!url.is_empty()).then(|| SnapshotEntry {
|
||||||
|
url: url.to_owned(),
|
||||||
|
subscribers: 0,
|
||||||
|
// "Podcasts" is the root Apple files a show under when it gives no category.
|
||||||
|
category: r.genre.clone().filter(|g| g != "Podcasts"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apples_categories_are_read_and_compared_with_ipxs() {
|
||||||
|
let genres: Genres = serde_json::from_str(
|
||||||
|
r#"{"26": {"name": "Podcasts", "subgenres": {
|
||||||
|
"1301": {"name": "Arts", "subgenres": {"1482": {"name": "Books"}, "1999": {"name": "Murals"}}},
|
||||||
|
"1310": {"name": "Music"}}}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(genre_ids(&genres), ["1301", "1310"]);
|
||||||
|
let ours: &[(&str, &[&str])] = &[("Arts", &["Books", "Design"]), ("Music", &[]), ("Hobbies", &[])];
|
||||||
|
let (added, gone) = differ(&categories(&genres), ours);
|
||||||
|
assert_eq!(added, ["Arts / Murals"]);
|
||||||
|
assert_eq!(gone, ["Arts / Design", "Hobbies"]);
|
||||||
|
// ipx's own copy is Apple's whole list, each category once.
|
||||||
|
let mine = ipx_feed::categories();
|
||||||
|
assert_eq!(mine.len(), 19);
|
||||||
|
assert!(mine.iter().any(|(c, subs)| *c == "News" && subs.contains(&"Daily News")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_chart_gives_its_shows_in_order_and_a_lookup_their_addresses() {
|
||||||
|
let chart: Chart = serde_json::from_str(
|
||||||
|
r#"{"feed": {"entry": [
|
||||||
|
{"id": {"label": "https://x", "attributes": {"im:id": "1200361736"}}},
|
||||||
|
{"id": {"label": "https://y", "attributes": {"im:id": "1028908750"}}}]}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(chart_ids(&chart), ["1200361736", "1028908750"]);
|
||||||
|
let one: Chart =
|
||||||
|
serde_json::from_str(r#"{"feed": {"entry": {"id": {"attributes": {"im:id": "7"}}}}}"#).unwrap();
|
||||||
|
assert_eq!(chart_ids(&one), ["7"], "a chart of one");
|
||||||
|
let empty: Chart = serde_json::from_str(r#"{"feed": {}}"#).unwrap();
|
||||||
|
assert!(chart_ids(&empty).is_empty());
|
||||||
|
|
||||||
|
let found: Lookup = serde_json::from_str(
|
||||||
|
r#"{"resultCount": 3, "results": [
|
||||||
|
{"trackName": "The Daily", "feedUrl": "https://feeds.simplecast.com/Sl5CSM3S", "primaryGenreName": "Daily News"},
|
||||||
|
{"trackName": "No feed"},
|
||||||
|
{"trackName": "Rooted", "feedUrl": "https://example.com/rss", "primaryGenreName": "Podcasts"}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let entries = lookup_entries(&found);
|
||||||
|
assert_eq!(entries.len(), 2, "a show Apple has no feed for is left out");
|
||||||
|
assert_eq!((entries[0].subscribers, entries[0].category.as_deref()), (0, Some("Daily News")));
|
||||||
|
assert_eq!(entries[1].category, None, "Podcasts is no category");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,11 @@ pub struct Config {
|
|||||||
/// publishes: `0.0.0.0:9188`. Empty serves none.
|
/// publishes: `0.0.0.0:9188`. Empty serves none.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub metrics_bind: String,
|
pub metrics_bind: String,
|
||||||
|
/// Apple Podcasts' top charts for this country (its two letters: `ca`, `us`) come into the
|
||||||
|
/// directory each week, and Apple's categories are checked against ipx's (#252). Empty, or
|
||||||
|
/// left out, does neither.
|
||||||
|
#[serde(default)]
|
||||||
|
pub apple_country: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tokens: Vec<TokenCfg>,
|
pub tokens: Vec<TokenCfg>,
|
||||||
}
|
}
|
||||||
@@ -81,6 +86,14 @@ impl Config {
|
|||||||
if !seen.insert(t.name.as_str()) {
|
if !seen.insert(t.name.as_str()) {
|
||||||
bail!("two tokens are named {:?}", t.name);
|
bail!("two tokens are named {:?}", t.name);
|
||||||
}
|
}
|
||||||
|
// Apple's charts are filed under it; a server of that name would replace them.
|
||||||
|
if t.name == crate::apple::INSTANCE {
|
||||||
|
bail!("{:?} is the name Apple Podcasts' charts are kept under; give the token another", t.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let c = cfg.apple_country.trim();
|
||||||
|
if !c.is_empty() && !(c.len() == 2 && c.chars().all(|c| c.is_ascii_alphabetic())) {
|
||||||
|
bail!("apple_country is a country's two letters, such as \"ca\" or \"us\", not {c:?}");
|
||||||
}
|
}
|
||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
@@ -149,7 +162,10 @@ mod tests {
|
|||||||
let cfg = Config::parse(GOOD).unwrap();
|
let cfg = Config::parse(GOOD).unwrap();
|
||||||
assert_eq!(cfg.bind, "0.0.0.0:8100");
|
assert_eq!(cfg.bind, "0.0.0.0:8100");
|
||||||
let t = cfg.tokens().unwrap();
|
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[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_eq!(t[1].access, Access::Ro);
|
||||||
assert!(Access::Ro < Access::Rw && Access::Rw < Access::Rwa);
|
assert!(Access::Ro < Access::Rw && Access::Rw < Access::Rwa);
|
||||||
}
|
}
|
||||||
@@ -158,7 +174,9 @@ mod tests {
|
|||||||
fn a_missing_or_misspelled_access_a_reused_name_or_a_bad_hash_is_refused() {
|
fn a_missing_or_misspelled_access_a_reused_name_or_a_bad_hash_is_refused() {
|
||||||
let h = "a".repeat(64);
|
let h = "a".repeat(64);
|
||||||
assert!(Config::parse(&format!("[[tokens]]\nname = \"a\"\ntoken_sha256 = \"{h}\"")).is_err());
|
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(&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("\"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), "xyz")).unwrap().tokens().is_err());
|
||||||
assert!(Config::parse(&GOOD.replace(&"b".repeat(64), &h)).unwrap().tokens().is_err());
|
assert!(Config::parse(&GOOD.replace(&"b".repeat(64), &h)).unwrap().tokens().is_err());
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//! The types here are the wire format, shared with ipx, which asks the directory on its
|
//! The types here are the wire format, shared with ipx, which asks the directory on its
|
||||||
//! people's behalf.
|
//! people's behalf.
|
||||||
|
|
||||||
|
pub mod apple;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod otel;
|
pub mod otel;
|
||||||
@@ -117,8 +118,8 @@ pub struct Blocked {
|
|||||||
pub const MAX_SNAPSHOT: usize = 20_000;
|
pub const MAX_SNAPSHOT: usize = 20_000;
|
||||||
|
|
||||||
/// How often each feed is fetched again. Metadata changes slowly, and every server subscribed
|
/// How often each feed is fetched again. Metadata changes slowly, and every server subscribed
|
||||||
/// to a feed fetches it itself anyway.
|
/// to a feed fetches it itself anyway: every six hours was 3.9 GB of feeds a day (#243).
|
||||||
pub const REFRESH_EVERY: i64 = 6 * 3600;
|
pub const REFRESH_EVERY: i64 = 12 * 3600;
|
||||||
|
|
||||||
pub struct Directory {
|
pub struct Directory {
|
||||||
pub store: Store,
|
pub store: Store,
|
||||||
@@ -152,16 +153,16 @@ impl Directory {
|
|||||||
fn caller(&self, presented: &str) -> Option<Me> {
|
fn caller(&self, presented: &str) -> Option<Me> {
|
||||||
let h = config::hash(presented);
|
let h = config::hash(presented);
|
||||||
// Every token compared, whether or not one matched, so timing says nothing either way.
|
// Every token compared, whether or not one matched, so timing says nothing either way.
|
||||||
self.tokens
|
self.tokens.read().unwrap().iter().fold(None, |found, t| {
|
||||||
.read()
|
if config::same_secret(&t.sha256, &h) { Some(Me { name: t.name.clone(), access: t.access }) } else { found }
|
||||||
.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.
|
/// Fetches one feed and stores what it found.
|
||||||
pub async fn refresh(&self, due: &store::Due) -> anyhow::Result<()> {
|
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 {
|
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::NotModified, _)) => Refreshed::NotModified,
|
||||||
Ok((ipx_feed::Fetched::Body { bytes, etag, last_modified }, _)) => {
|
Ok((ipx_feed::Fetched::Body { bytes, etag, last_modified }, _)) => {
|
||||||
if ipx_feed::is_opml(&bytes) {
|
if ipx_feed::is_opml(&bytes) {
|
||||||
@@ -241,10 +242,14 @@ pub async fn reloader(dir: Shared, path: std::path::PathBuf) {
|
|||||||
let n = tokens.len();
|
let n = tokens.len();
|
||||||
match dir.set_tokens(tokens).await {
|
match dir.set_tokens(tokens).await {
|
||||||
Ok(dropped) => tracing::info!(tokens = n, dropped, "config read again"),
|
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 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"),
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "config changed but does not load; keeping the tokens as they were")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,6 +272,12 @@ pub fn router(dir: Shared) -> Router {
|
|||||||
.merge(v1)
|
.merge(v1)
|
||||||
// Anyone may browse, no token, read only (#215).
|
// Anyone may browse, no token, read only (#215).
|
||||||
.merge(site::router())
|
.merge(site::router())
|
||||||
|
// Brotli, zstd or gzip as the caller takes them: the feed list went to ipx as 603 KB of
|
||||||
|
// JSON, most of a Directory page's time (#253). Inside the access log, which records what
|
||||||
|
// was sent.
|
||||||
|
.layer(tower_http::compression::CompressionLayer::new().br(true).zstd(true).gzip(true))
|
||||||
|
// And takes a request's body gzipped: ipx sends its snapshot so, about 100 KB as JSON.
|
||||||
|
.layer(tower_http::decompression::RequestDecompressionLayer::new().gzip(true))
|
||||||
.layer(axum::middleware::from_fn(access_log))
|
.layer(axum::middleware::from_fn(access_log))
|
||||||
.with_state(dir)
|
.with_state(dir)
|
||||||
}
|
}
|
||||||
@@ -335,7 +346,8 @@ async fn authenticate(State(dir): State<Shared>, mut req: Request, next: Next) -
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|t| !t.is_empty());
|
.filter(|t| !t.is_empty());
|
||||||
let Some(me) = presented.and_then(|t| dir.caller(t)) else {
|
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();
|
return ApiError(StatusCode::UNAUTHORIZED, "a valid token is needed: Authorization: Bearer <token>".into())
|
||||||
|
.into_response();
|
||||||
};
|
};
|
||||||
let who = Who(me.name.clone());
|
let who = Who(me.name.clone());
|
||||||
req.extensions_mut().insert(me);
|
req.extensions_mut().insert(me);
|
||||||
@@ -380,7 +392,11 @@ async fn popular(State(dir): State<Shared>, Query(q): Query<Limit>) -> Result<Js
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Result<Json<FeedPage>, ApiError> {
|
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")))
|
dir.store
|
||||||
|
.feed(&id)
|
||||||
|
.await?
|
||||||
|
.map(Json)
|
||||||
|
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not in the directory")))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn snapshot(
|
async fn snapshot(
|
||||||
@@ -401,7 +417,11 @@ async fn snapshot(
|
|||||||
Ok(Json(r))
|
Ok(Json(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
|
async fn remove(
|
||||||
|
State(dir): State<Shared>,
|
||||||
|
Extension(me): Extension<Me>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
need(&me, Access::Rwa)?;
|
need(&me, Access::Rwa)?;
|
||||||
dir.store.block(&id).await?;
|
dir.store.block(&id).await?;
|
||||||
tracing::info!(feed = %id, by = %me.name, "feed removed and blocked");
|
tracing::info!(feed = %id, by = %me.name, "feed removed and blocked");
|
||||||
@@ -413,7 +433,11 @@ async fn blocked(State(dir): State<Shared>, Extension(me): Extension<Me>) -> Res
|
|||||||
Ok(Json(dir.store.blocked().await?))
|
Ok(Json(dir.store.blocked().await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn unblock(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
|
async fn unblock(
|
||||||
|
State(dir): State<Shared>,
|
||||||
|
Extension(me): Extension<Me>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
need(&me, Access::Rwa)?;
|
need(&me, Access::Rwa)?;
|
||||||
if !dir.store.unblock(&id).await? {
|
if !dir.store.unblock(&id).await? {
|
||||||
return Err(ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not blocked")));
|
return Err(ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not blocked")));
|
||||||
@@ -427,16 +451,28 @@ async fn instances(State(dir): State<Shared>, Extension(me): Extension<Me>) -> R
|
|||||||
Ok(Json(dir.store.instances().await?))
|
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> {
|
async fn drop_instance(
|
||||||
|
State(dir): State<Shared>,
|
||||||
|
Extension(me): Extension<Me>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
need(&me, Access::Rwa)?;
|
need(&me, Access::Rwa)?;
|
||||||
let n = dir.store.drop_instance(&name).await?;
|
let n = dir.store.drop_instance(&name).await?;
|
||||||
tracing::info!(instance = %name, feeds = n, by = %me.name, "snapshot dropped");
|
tracing::info!(instance = %name, feeds = n, by = %me.name, "snapshot dropped");
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn refresh(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(id): Path<String>) -> Result<Json<FeedPage>, ApiError> {
|
async fn refresh(
|
||||||
|
State(dir): State<Shared>,
|
||||||
|
Extension(me): Extension<Me>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<FeedPage>, ApiError> {
|
||||||
need(&me, Access::Rwa)?;
|
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")))?;
|
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?;
|
dir.refresh(&due).await?;
|
||||||
feed(State(dir), Path(id)).await
|
feed(State(dir), Path(id)).await
|
||||||
}
|
}
|
||||||
@@ -460,7 +496,13 @@ mod tests {
|
|||||||
(router(dir.clone()), dir)
|
(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) {
|
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);
|
let mut req = axum::http::Request::builder().method(method).uri(path);
|
||||||
if let Some(t) = token {
|
if let Some(t) = token {
|
||||||
req = req.header("authorization", format!("Bearer secret-{t}"));
|
req = req.header("authorization", format!("Bearer secret-{t}"));
|
||||||
@@ -476,6 +518,43 @@ mod tests {
|
|||||||
(status, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null))
|
(status, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_snapshot_may_come_gzipped() {
|
||||||
|
use std::io::Write as _;
|
||||||
|
let (app, _) = app().await;
|
||||||
|
let json = serde_json::json!([{"url": "https://example.com/feed.xml", "subscribers": 2}]).to_string();
|
||||||
|
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||||
|
gz.write_all(json.as_bytes()).unwrap();
|
||||||
|
// As ipx sends it (#253).
|
||||||
|
let req = axum::http::Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/v1/snapshot")
|
||||||
|
.header("authorization", "Bearer secret-rw")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("content-encoding", "gzip")
|
||||||
|
.body(Body::from(gz.finish().unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), 200);
|
||||||
|
let got: serde_json::Value =
|
||||||
|
serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap()).unwrap();
|
||||||
|
assert_eq!(got["accepted"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn answers_come_compressed_as_the_caller_asks() {
|
||||||
|
let (app, _) = app().await;
|
||||||
|
for (asked, sent) in [("br", Some("br")), ("zstd", Some("zstd")), ("gzip", Some("gzip")), ("identity", None)] {
|
||||||
|
// A page, not an empty feed list: under 32 bytes is sent as it is.
|
||||||
|
let req =
|
||||||
|
axum::http::Request::builder().uri("/").header("accept-encoding", asked).body(Body::empty()).unwrap();
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), 200);
|
||||||
|
let got = resp.headers().get("content-encoding").map(|v| v.to_str().unwrap().to_owned());
|
||||||
|
assert_eq!(got.as_deref(), sent, "asked for {asked} (#253)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_page(app: &Router, path: &str) -> (u16, String, Option<String>) {
|
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 req = axum::http::Request::builder().uri(path).body(Body::empty()).unwrap();
|
||||||
let resp = app.clone().oneshot(req).await.unwrap();
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
@@ -537,7 +616,11 @@ mod tests {
|
|||||||
assert_eq!(call(&app, "GET", "/v1/feeds/nope", Some("ro"), None).await.0, 404);
|
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());
|
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())] {
|
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, 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, "DELETE", &format!("/v1/feeds/{id}"), Some("rwa"), None).await.0, 204);
|
||||||
|
|||||||
@@ -74,15 +74,24 @@ async fn main() -> Result<()> {
|
|||||||
async fn serve(path: &std::path::Path) -> Result<()> {
|
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.
|
// 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 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());
|
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).
|
// Traces to Tempo when OTEL_EXPORTER_OTLP_ENDPOINT is set, joining ipx's (#213).
|
||||||
let otel = ipx_directory::otel::provider("ipx-directory")?;
|
let otel = ipx_directory::otel::provider("ipx-directory")?;
|
||||||
tracing_subscriber::registry()
|
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().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(json.then(|| {
|
||||||
|
tracing_subscriber::fmt::layer()
|
||||||
|
.json()
|
||||||
|
.flatten_event(true)
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.with_filter(filter())
|
||||||
|
}))
|
||||||
.with(otel.as_ref().map(|p| {
|
.with(otel.as_ref().map(|p| {
|
||||||
use opentelemetry::trace::TracerProvider;
|
use opentelemetry::trace::TracerProvider;
|
||||||
tracing_opentelemetry::layer().with_tracer(p.tracer("ipx-directory")).with_filter(tracing_subscriber::EnvFilter::new("ipx_directory=info,ipx_feed=info"))
|
tracing_opentelemetry::layer()
|
||||||
|
.with_tracer(p.tracer("ipx-directory"))
|
||||||
|
.with_filter(tracing_subscriber::EnvFilter::new("ipx_directory=info,ipx_feed=info"))
|
||||||
}))
|
}))
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
@@ -92,22 +101,31 @@ async fn serve(path: &std::path::Path) -> Result<()> {
|
|||||||
.ok()
|
.ok()
|
||||||
.filter(|u| !u.trim().is_empty())
|
.filter(|u| !u.trim().is_empty())
|
||||||
.with_context(|| format!("{} is not set: the directory needs a database URL", cfg.database_url_env))?;
|
.with_context(|| format!("{} is not set: the directory needs a database URL", cfg.database_url_env))?;
|
||||||
let store = Store::open(&url).await.context("opening the database")?;
|
let store = Store::open(&url, true).await.context("opening the database")?;
|
||||||
let dir = Directory::new(store, vec![])?;
|
let dir = Directory::new(store, vec![])?;
|
||||||
let dropped = dir.set_tokens(tokens).await?;
|
let dropped = dir.set_tokens(tokens).await?;
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(&cfg.bind).await.with_context(|| format!("listening on {}", cfg.bind))?;
|
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");
|
tracing::info!(version = env!("CARGO_PKG_VERSION"), bind = %cfg.bind, tokens = cfg.tokens.len(), dropped, "directory started");
|
||||||
if !cfg.metrics_bind.is_empty() {
|
if !cfg.metrics_bind.is_empty() {
|
||||||
let metrics = tokio::net::TcpListener::bind(&cfg.metrics_bind).await.with_context(|| format!("listening for metrics on {}", cfg.metrics_bind))?;
|
let metrics = tokio::net::TcpListener::bind(&cfg.metrics_bind)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("listening for metrics on {}", cfg.metrics_bind))?;
|
||||||
tracing::info!(bind = %cfg.metrics_bind, "serving metrics for Prometheus at /metrics");
|
tracing::info!(bind = %cfg.metrics_bind, "serving metrics for Prometheus at /metrics");
|
||||||
tokio::spawn(ipx_directory::metrics::serve(metrics));
|
tokio::spawn(ipx_directory::metrics::serve(metrics));
|
||||||
}
|
}
|
||||||
tokio::spawn(ipx_directory::refresher(dir.clone()));
|
tokio::spawn(ipx_directory::refresher(dir.clone()));
|
||||||
|
let country = cfg.apple_country.trim().to_ascii_lowercase();
|
||||||
|
if !country.is_empty() {
|
||||||
|
tracing::info!(country = %country, "Apple Podcasts' charts are synced weekly");
|
||||||
|
tokio::spawn(ipx_directory::apple::run(dir.clone(), country));
|
||||||
|
}
|
||||||
tokio::spawn(ipx_directory::reloader(dir.clone(), path.to_owned()));
|
tokio::spawn(ipx_directory::reloader(dir.clone(), path.to_owned()));
|
||||||
axum::serve(listener, ipx_directory::router(dir))
|
axum::serve(listener, ipx_directory::router(dir))
|
||||||
.with_graceful_shutdown(async {
|
.with_graceful_shutdown(async {
|
||||||
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).expect("SIGTERM handler");
|
let mut term =
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).expect("SIGTERM handler");
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = term.recv() => {}
|
_ = term.recv() => {}
|
||||||
_ = tokio::signal::ctrl_c() => {}
|
_ = tokio::signal::ctrl_c() => {}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
|||||||
/// The process's resident memory in bytes and its open files, from /proc; None elsewhere.
|
/// The process's resident memory in bytes and its open files, from /proc; None elsewhere.
|
||||||
pub fn process() -> (Option<f64>, Option<f64>) {
|
pub fn process() -> (Option<f64>, Option<f64>) {
|
||||||
let rss = std::fs::read_to_string("/proc/self/status").ok().and_then(|s| {
|
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()?;
|
let kb =
|
||||||
|
s.lines().find_map(|l| l.strip_prefix("VmRSS:"))?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()?;
|
||||||
Some(kb * 1024.0)
|
Some(kb * 1024.0)
|
||||||
});
|
});
|
||||||
let fds = std::fs::read_dir("/proc/self/fd").ok().map(|d| d.count() as f64);
|
let fds = std::fs::read_dir("/proc/self/fd").ok().map(|d| d.count() as f64);
|
||||||
@@ -35,10 +36,34 @@ fn render(started: std::time::Instant) -> String {
|
|||||||
if let Some(v) = fds {
|
if let Some(v) = fds {
|
||||||
gauge("directory_process_open_fds", "gauge", "Files and sockets the process has open.", "", v);
|
gauge("directory_process_open_fds", "gauge", "Files and sockets the process has open.", "", v);
|
||||||
}
|
}
|
||||||
gauge("directory_feed_fetches_total", "counter", "Feeds fetched, answered 304 included.", "", ipx_feed::FETCHES.load(Relaxed) as f64);
|
gauge(
|
||||||
gauge("directory_feed_bytes_total", "counter", "Bytes of feed bodies fetched.", "", ipx_feed::FETCHED_BYTES.load(Relaxed) as f64);
|
"directory_feed_fetches_total",
|
||||||
gauge("directory_uptime_seconds", "gauge", "Seconds since the directory started.", "", started.elapsed().as_secs() as f64);
|
"counter",
|
||||||
gauge("directory_build_info", "gauge", "The version running, as a label.", &format!("{{version=\"{}\"}}", env!("CARGO_PKG_VERSION")), 1.0);
|
"Feeds fetched, answered 304 included.",
|
||||||
|
"",
|
||||||
|
ipx_feed::FETCHES.load(Relaxed) as f64,
|
||||||
|
);
|
||||||
|
gauge(
|
||||||
|
"directory_feed_bytes_total",
|
||||||
|
"counter",
|
||||||
|
"Bytes of feed bodies fetched.",
|
||||||
|
"",
|
||||||
|
ipx_feed::FETCHED_BYTES.load(Relaxed) as f64,
|
||||||
|
);
|
||||||
|
gauge(
|
||||||
|
"directory_uptime_seconds",
|
||||||
|
"gauge",
|
||||||
|
"Seconds since the directory started.",
|
||||||
|
"",
|
||||||
|
started.elapsed().as_secs() as f64,
|
||||||
|
);
|
||||||
|
gauge(
|
||||||
|
"directory_build_info",
|
||||||
|
"gauge",
|
||||||
|
"The version running, as a label.",
|
||||||
|
&format!("{{version=\"{}\"}}", env!("CARGO_PKG_VERSION")),
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,12 +71,15 @@ fn render(started: std::time::Instant) -> String {
|
|||||||
/// Access in front, and these are for the LAN.
|
/// Access in front, and these are for the LAN.
|
||||||
pub async fn serve(listener: tokio::net::TcpListener) {
|
pub async fn serve(listener: tokio::net::TcpListener) {
|
||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
let app = axum::Router::new().route(
|
let app = axum::Router::new()
|
||||||
"/metrics",
|
.route(
|
||||||
axum::routing::get(move || async move {
|
"/metrics",
|
||||||
([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(started))
|
axum::routing::get(move || async move {
|
||||||
}),
|
([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(started))
|
||||||
);
|
}),
|
||||||
|
)
|
||||||
|
// Prometheus asks for gzip (#253).
|
||||||
|
.layer(tower_http::compression::CompressionLayer::new().gzip(true));
|
||||||
if let Err(e) = axum::serve(listener, app).await {
|
if let Err(e) = axum::serve(listener, app).await {
|
||||||
tracing::error!(error = %e, "metrics stopped");
|
tracing::error!(error = %e, "metrics stopped");
|
||||||
}
|
}
|
||||||
@@ -62,6 +90,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn metrics_say_memory_and_feeds() {
|
fn metrics_say_memory_and_feeds() {
|
||||||
let text = super::render(std::time::Instant::now());
|
let text = super::render(std::time::Instant::now());
|
||||||
assert!(text.contains("directory_process_resident_bytes ") && text.contains("directory_feed_bytes_total "), "{text}");
|
assert!(
|
||||||
|
text.contains("directory_process_resident_bytes ") && text.contains("directory_feed_bytes_total "),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,13 +70,21 @@ mod tests {
|
|||||||
assert_eq!(cx.span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
|
assert_eq!(cx.span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||||
assert!(cx.span().span_context().is_remote());
|
assert!(cx.span().span_context().is_remote());
|
||||||
// Anything else from the browser starts a trace of its own.
|
// Anything else from the browser starts a trace of its own.
|
||||||
for bad in ["", "00-xyz-00f067aa0ba902b7-01", "01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
|
for bad in [
|
||||||
"00-00000000000000000000000000000000-00f067aa0ba902b7-01", "00-4bf92f3577b34da6a3ce929d0e0e4736"] {
|
"",
|
||||||
|
"00-xyz-00f067aa0ba902b7-01",
|
||||||
|
"01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
|
||||||
|
"00-00000000000000000000000000000000-00f067aa0ba902b7-01",
|
||||||
|
"00-4bf92f3577b34da6a3ce929d0e0e4736",
|
||||||
|
] {
|
||||||
assert!(with(bad).is_none(), "{bad}");
|
assert!(with(bad).is_none(), "{bad}");
|
||||||
}
|
}
|
||||||
assert!(remote_parent(&axum::http::HeaderMap::new(), None).is_none());
|
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).
|
// 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"));
|
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_eq!(q.unwrap().span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||||
assert!(remote_parent(&axum::http::HeaderMap::new(), Some("traceparent=nonsense")).is_none());
|
assert!(remote_parent(&axum::http::HeaderMap::new(), Some("traceparent=nonsense")).is_none());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::{HeaderValue, StatusCode, header};
|
use axum::http::{HeaderValue, StatusCode, header};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::Router;
|
|
||||||
|
|
||||||
use crate::{Listing, Shared};
|
use crate::{Listing, Shared};
|
||||||
|
|
||||||
@@ -63,7 +63,9 @@ async fn home(State(dir): State<Shared>) -> Response {
|
|||||||
Err(e) => return failed(e),
|
Err(e) => return failed(e),
|
||||||
};
|
};
|
||||||
let mut top = all.clone();
|
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.sort_by(|a, b| {
|
||||||
|
b.subscribers.cmp(&a.subscribers).then_with(|| name(a).to_lowercase().cmp(&name(b).to_lowercase()))
|
||||||
|
});
|
||||||
top.truncate(10);
|
top.truncate(10);
|
||||||
let mut cats: BTreeMap<&str, Vec<&Listing>> = BTreeMap::new();
|
let mut cats: BTreeMap<&str, Vec<&Listing>> = BTreeMap::new();
|
||||||
for l in &all {
|
for l in &all {
|
||||||
@@ -75,12 +77,25 @@ async fn home(State(dir): State<Shared>) -> Response {
|
|||||||
} else {
|
} else {
|
||||||
b.push_str("<h2>Most subscribed</h2><ol class=chart>");
|
b.push_str("<h2>Most subscribed</h2><ol class=chart>");
|
||||||
for (i, l) in top.iter().enumerate() {
|
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>",
|
let _ = write!(
|
||||||
esc(&l.id), i + 1, art(l, "art sm"), esc(name(l)), subscribers(l.subscribers));
|
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>");
|
b.push_str("</ol><h2>Categories</h2><div class=cats>");
|
||||||
for (c, ls) in &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,
|
||||||
|
"<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());
|
let _ = write!(b, "</div><p><a class=more href=\"/all\">All {} feeds, A to Z</a></p>", all.len());
|
||||||
}
|
}
|
||||||
@@ -115,10 +130,11 @@ async fn listed(dir: &Shared, title: &str, here: &str, f: &Filter, pick: impl Fn
|
|||||||
if ls.is_empty() && here.starts_with("/category/") {
|
if ls.is_empty() && here.starts_with("/category/") {
|
||||||
return not_found("That category has nothing in it.");
|
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| {
|
let subs: BTreeMap<String, usize> =
|
||||||
*m.entry(s).or_default() += 1;
|
ls.iter().filter_map(|l| l.subcategory.clone()).fold(BTreeMap::new(), |mut m, s| {
|
||||||
m
|
*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.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());
|
ls.sort_by_key(|l| name(l).to_lowercase());
|
||||||
|
|
||||||
@@ -138,18 +154,38 @@ async fn listed(dir: &Shared, title: &str, here: &str, f: &Filter, pick: impl Fn
|
|||||||
if q.is_empty() { here.to_owned() } else { format!("{here}?{q}") }
|
if q.is_empty() { here.to_owned() } else { format!("{here}?{q}") }
|
||||||
};
|
};
|
||||||
let mut b = String::new();
|
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));
|
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")] {
|
for (k, label) in [(None, "All"), (Some("podcasts"), "Podcasts"), (Some("blogs"), "Blogs")] {
|
||||||
let on = f.kind.as_deref() == k;
|
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 { "" });
|
let _ = write!(
|
||||||
|
b,
|
||||||
|
"<a href=\"{}\"{}>{label}</a>",
|
||||||
|
esc(&link(k, f.sub.as_deref())),
|
||||||
|
if on { " aria-current=page" } else { "" }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
b.push_str("</nav>");
|
b.push_str("</nav>");
|
||||||
if !subs.is_empty() {
|
if !subs.is_empty() {
|
||||||
b.push_str("<nav class=pills aria-label=Subcategory>");
|
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 { "" });
|
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 {
|
for (s, n) in &subs {
|
||||||
let on = f.sub.as_deref() == Some(s.as_str());
|
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));
|
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>");
|
b.push_str("</nav>");
|
||||||
}
|
}
|
||||||
@@ -158,7 +194,14 @@ async fn listed(dir: &Shared, title: &str, here: &str, f: &Filter, pick: impl Fn
|
|||||||
} else {
|
} else {
|
||||||
let _ = write!(b, "<p class=count>{}</p><div class=tiles>", plural(ls.len(), "feed"));
|
let _ = write!(b, "<p class=count>{}</p><div class=tiles>", plural(ls.len(), "feed"));
|
||||||
for l in &ls {
|
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));
|
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>");
|
b.push_str("</div>");
|
||||||
}
|
}
|
||||||
@@ -177,11 +220,21 @@ async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Response {
|
|||||||
if let Some(c) = &l.category {
|
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, " <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)),
|
let _ = write!(
|
||||||
subscribers(l.subscribers), l.subcategory.as_deref().map(|s| format!(" · {}", esc(s))).unwrap_or_default());
|
b,
|
||||||
let _ = write!(b, "<div class=addr><label for=addr>Feed address</label><span class=row><input id=addr readonly value=\"{}\" spellcheck=false>\
|
"</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>",
|
<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));
|
esc(&l.url),
|
||||||
|
esc(&l.url)
|
||||||
|
);
|
||||||
if let Some(d) = p.description.as_deref().map(text).filter(|d| !d.is_empty() && d != name(l)) {
|
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));
|
let _ = write!(b, "<p class=about>{}</p>", esc(&d));
|
||||||
}
|
}
|
||||||
@@ -199,17 +252,26 @@ async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Response {
|
|||||||
None => esc(&title),
|
None => esc(&title),
|
||||||
};
|
};
|
||||||
let blurb: String = e.description.as_deref().map(text).unwrap_or_default().chars().take(400).collect();
|
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 when = [e.published.map(date), e.duration.filter(|d| *d > 0).map(clock)]
|
||||||
let _ = write!(b, "<article><b>{head}</b>{}{}</article>",
|
.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 blurb.is_empty() { String::new() } else { format!("<p>{}</p>", esc(&blurb)) },
|
||||||
if when.is_empty() { String::new() } else { format!("<small>{when}</small>") });
|
if when.is_empty() { String::new() } else { format!("<small>{when}</small>") }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
page(name(l), Some(&text(p.description.as_deref().unwrap_or(""))), &b)
|
page(name(l), Some(&text(p.description.as_deref().unwrap_or(""))), &b)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn page(title: &str, description: Option<&str>, body: &str) -> Response {
|
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 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>())
|
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());
|
.unwrap_or_else(|| "Public podcasts and blogs that iPX servers share.".into());
|
||||||
let html = format!(
|
let html = format!(
|
||||||
"<!doctype html><html lang=en><head><meta charset=utf-8><meta name=viewport content=\"width=device-width,initial-scale=1\">\
|
"<!doctype html><html lang=en><head><meta charset=utf-8><meta name=viewport content=\"width=device-width,initial-scale=1\">\
|
||||||
@@ -234,14 +296,22 @@ fn page(title: &str, description: Option<&str>, body: &str) -> Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn not_found(why: &str) -> Response {
|
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)));
|
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.status_mut() = StatusCode::NOT_FOUND;
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
|
|
||||||
fn failed(e: anyhow::Error) -> Response {
|
fn failed(e: anyhow::Error) -> Response {
|
||||||
tracing::error!(error = %format!("{e:#}"), "a directory page failed");
|
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>");
|
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.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
@@ -253,8 +323,12 @@ fn redirect(to: &'static str) -> Response {
|
|||||||
/// A cover from its publisher, over the feed's initials, which show where it has none or it fails.
|
/// 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 {
|
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.
|
// 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 initials: String =
|
||||||
let img = l.image.as_deref().filter(|u| u.starts_with("https://") || u.starts_with("http://"))
|
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)))
|
.map(|u| format!("<img src=\"{}\" alt=\"\" loading=lazy referrerpolicy=no-referrer>", esc(u)))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
format!("<span class=\"{class}\" aria-hidden=true><i>{}</i>{img}</span>", esc(&initials.to_uppercase()))
|
format!("<span class=\"{class}\" aria-hidden=true><i>{}</i>{img}</span>", esc(&initials.to_uppercase()))
|
||||||
@@ -301,7 +375,13 @@ fn clock(secs: i64) -> String {
|
|||||||
fn text(html: &str) -> String {
|
fn text(html: &str) -> String {
|
||||||
// A space where each tag was, or two paragraphs run together: "drives.For over".
|
// 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 stripped = ammonia::Builder::empty().clean(&html.replace('<', " <")).to_string();
|
||||||
let decoded = stripped.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace(" ", " ").replace("&", "&");
|
let decoded = stripped
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace(" ", " ")
|
||||||
|
.replace("&", "&");
|
||||||
decoded.split_whitespace().collect::<Vec<_>>().join(" ")
|
decoded.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,11 +28,7 @@ pub struct Due {
|
|||||||
/// What a fetch found, to be stored.
|
/// What a fetch found, to be stored.
|
||||||
pub enum Refreshed {
|
pub enum Refreshed {
|
||||||
NotModified,
|
NotModified,
|
||||||
Feed {
|
Feed { parsed: ipx_feed::ParsedFeed, etag: Option<String>, last_modified: Option<String> },
|
||||||
parsed: ipx_feed::ParsedFeed,
|
|
||||||
etag: Option<String>,
|
|
||||||
last_modified: Option<String>,
|
|
||||||
},
|
|
||||||
Failed(String),
|
Failed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +80,11 @@ const SCHEMA: &[&str] = &[
|
|||||||
PRIMARY KEY (instance, feed_id)
|
PRIMARY KEY (instance, feed_id)
|
||||||
)",
|
)",
|
||||||
"CREATE INDEX IF NOT EXISTS snapshots_feed ON snapshots (feed_id)",
|
"CREATE INDEX IF NOT EXISTS snapshots_feed ON snapshots (feed_id)",
|
||||||
|
// When each periodic job last finished: Apple's weekly sync (#252).
|
||||||
|
"CREATE TABLE IF NOT EXISTS syncs (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
at BIGINT NOT NULL
|
||||||
|
)",
|
||||||
"CREATE TABLE IF NOT EXISTS blocked (
|
"CREATE TABLE IF NOT EXISTS blocked (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
url TEXT,
|
url TEXT,
|
||||||
@@ -99,7 +100,7 @@ impl Store {
|
|||||||
pub async fn scratch() -> Self {
|
pub async fn scratch() -> Self {
|
||||||
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
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 {
|
let Some(url) = std::env::var("IPX_TEST_DATABASE_URL").ok().filter(|u| !u.is_empty()) else {
|
||||||
return Self::open("sqlite::memory:").await.unwrap();
|
return Self::open("sqlite::memory:", false).await.unwrap();
|
||||||
};
|
};
|
||||||
let (pid, n) = (std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
|
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
|
// A bare connection: Store::open would make the tables in the public schema, and the
|
||||||
@@ -117,10 +118,16 @@ impl Store {
|
|||||||
let schema = format!("ipxd_{pid}_{n}");
|
let schema = format!("ipxd_{pid}_{n}");
|
||||||
admin.db.execute_unprepared(&format!("CREATE SCHEMA {schema}")).await.unwrap();
|
admin.db.execute_unprepared(&format!("CREATE SCHEMA {schema}")).await.unwrap();
|
||||||
let sep = if url.contains('?') { '&' } else { '?' };
|
let sep = if url.contains('?') { '&' } else { '?' };
|
||||||
Self::open(&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning")).await.unwrap()
|
Self::open(
|
||||||
|
&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn open(url: &str) -> Result<Self> {
|
/// `keep_open` for the server, which keeps its whole pool connected.
|
||||||
|
pub async fn open(url: &str, keep_open: bool) -> Result<Self> {
|
||||||
let mut url = url.to_owned();
|
let mut url = url.to_owned();
|
||||||
if url.starts_with("postgres") && !url.contains("options=") {
|
if url.starts_with("postgres") && !url.contains("options=") {
|
||||||
// Postgres sends a notice for every CREATE ... IF NOT EXISTS on something that exists;
|
// Postgres sends a notice for every CREATE ... IF NOT EXISTS on something that exists;
|
||||||
@@ -134,6 +141,11 @@ impl Store {
|
|||||||
if url.contains(":memory:") {
|
if url.contains(":memory:") {
|
||||||
opts.max_connections(1);
|
opts.max_connections(1);
|
||||||
}
|
}
|
||||||
|
// The server keeps every connection open, as ipx's daemon does: a Postgres connection takes
|
||||||
|
// 80-330 ms to open (#241). Not a test, which would hold ten for one question.
|
||||||
|
if keep_open && url.starts_with("postgres") {
|
||||||
|
opts.max_connections(10).min_connections(10);
|
||||||
|
}
|
||||||
let db = sea_orm::Database::connect(opts).await?;
|
let db = sea_orm::Database::connect(opts).await?;
|
||||||
for sql in SCHEMA {
|
for sql in SCHEMA {
|
||||||
db.execute_unprepared(sql).await?;
|
db.execute_unprepared(sql).await?;
|
||||||
@@ -158,6 +170,23 @@ impl Store {
|
|||||||
Ok(self.db.query_all_raw(self.stmt(sql, values)).await?)
|
Ok(self.db.query_all_raw(self.stmt(sql, values)).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When the job `name` last finished, Unix seconds.
|
||||||
|
pub async fn synced_at(&self, name: &str) -> Result<Option<i64>> {
|
||||||
|
Ok(match self.rows("SELECT at FROM syncs WHERE name = $1", vec![name.into()]).await?.first() {
|
||||||
|
Some(r) => Some(r.try_get("", "at")?),
|
||||||
|
None => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_synced(&self, name: &str, at: i64) -> Result<()> {
|
||||||
|
self.exec(
|
||||||
|
"INSERT INTO syncs (name, at) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET at = excluded.at",
|
||||||
|
vec![name.into(), at.into()],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn ping(&self) -> Result<()> {
|
pub async fn ping(&self) -> Result<()> {
|
||||||
self.rows("SELECT 1", vec![]).await?;
|
self.rows("SELECT 1", vec![]).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -167,8 +196,12 @@ impl Store {
|
|||||||
/// blocked feed is left out and counted; the rest are added to the feeds, new ones to be
|
/// 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.
|
/// fetched by the refresher. Feeds no instance sends any more go.
|
||||||
pub async fn snapshot(&self, instance: &str, entries: &[SnapshotEntry]) -> Result<SnapshotResult> {
|
pub async fn snapshot(&self, instance: &str, entries: &[SnapshotEntry]) -> Result<SnapshotResult> {
|
||||||
let blocked: std::collections::HashSet<String> =
|
let blocked: std::collections::HashSet<String> = self
|
||||||
self.rows("SELECT id FROM blocked", vec![]).await?.iter().map(|r| r.try_get("", "id")).collect::<Result<_, _>>()?;
|
.rows("SELECT id FROM blocked", vec![])
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.try_get("", "id"))
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
let mut result = SnapshotResult::default();
|
let mut result = SnapshotResult::default();
|
||||||
let mut keep = std::collections::BTreeMap::new();
|
let mut keep = std::collections::BTreeMap::new();
|
||||||
for e in entries {
|
for e in entries {
|
||||||
@@ -235,7 +268,8 @@ impl Store {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|r| r.try_get("", "instance"))
|
.map(|r| r.try_get("", "instance"))
|
||||||
.collect::<Result<_, _>>()?;
|
.collect::<Result<_, _>>()?;
|
||||||
for name in present.iter().filter(|n| !names.contains(n)) {
|
// Apple's charts are no token's, and stay until the next sync replaces them.
|
||||||
|
for name in present.iter().filter(|n| !names.contains(n) && n.as_str() != crate::apple::INSTANCE) {
|
||||||
dropped += self.drop_instance(name).await?;
|
dropped += self.drop_instance(name).await?;
|
||||||
}
|
}
|
||||||
Ok(dropped)
|
Ok(dropped)
|
||||||
@@ -343,7 +377,13 @@ impl Store {
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| Ok(Instance { name: r.try_get("", "instance")?, feeds: r.try_get("", "feeds")?, subscribers: r.try_get("", "subscribers")? }))
|
.map(|r| {
|
||||||
|
Ok(Instance {
|
||||||
|
name: r.try_get("", "instance")?,
|
||||||
|
feeds: r.try_get("", "feeds")?,
|
||||||
|
subscribers: r.try_get("", "subscribers")?,
|
||||||
|
})
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +391,14 @@ impl Store {
|
|||||||
self.rows("SELECT id, url, title, at FROM blocked ORDER BY at DESC, id", vec![])
|
self.rows("SELECT id, url, title, at FROM blocked ORDER BY at DESC, id", vec![])
|
||||||
.await?
|
.await?
|
||||||
.iter()
|
.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")? }))
|
.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()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,8 +424,7 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn due_one(&self, id: &str) -> Result<Option<Due>> {
|
pub async fn due_one(&self, id: &str) -> Result<Option<Due>> {
|
||||||
Ok(self
|
self.rows("SELECT id, url, etag, last_modified FROM feeds WHERE id = $1", vec![id.into()])
|
||||||
.rows("SELECT id, url, etag, last_modified FROM feeds WHERE id = $1", vec![id.into()])
|
|
||||||
.await?
|
.await?
|
||||||
.first()
|
.first()
|
||||||
.map(|r| -> Result<Due> {
|
.map(|r| -> Result<Due> {
|
||||||
@@ -389,14 +435,18 @@ impl Store {
|
|||||||
last_modified: r.try_get("", "last_modified")?,
|
last_modified: r.try_get("", "last_modified")?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.transpose()?)
|
.transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn save(&self, id: &str, got: Refreshed) -> Result<()> {
|
pub async fn save(&self, id: &str, got: Refreshed) -> Result<()> {
|
||||||
let t = now();
|
let t = now();
|
||||||
match got {
|
match got {
|
||||||
Refreshed::NotModified => {
|
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?;
|
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) => {
|
Refreshed::Failed(err) => {
|
||||||
self.exec(
|
self.exec(
|
||||||
@@ -407,16 +457,25 @@ impl Store {
|
|||||||
}
|
}
|
||||||
Refreshed::Feed { parsed, etag, last_modified } => {
|
Refreshed::Feed { parsed, etag, last_modified } => {
|
||||||
let path = parsed.category.as_deref().map(ipx_feed::category_path);
|
let path = parsed.category.as_deref().map(ipx_feed::category_path);
|
||||||
let podcast = parsed.entries.iter().flat_map(|e| &e.enclosures).any(|e| {
|
let podcast = parsed
|
||||||
e.mime.as_deref().is_some_and(|m| m.starts_with("audio/") || m.starts_with("video/"))
|
.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;
|
let mut entries = parsed.entries;
|
||||||
entries.sort_by_key(|e| std::cmp::Reverse(e.published));
|
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.
|
// What an item is, never its files: their addresses are where a key would be.
|
||||||
let items: Vec<Item> = entries
|
let items: Vec<Item> = entries
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.take(ITEMS)
|
.take(ITEMS)
|
||||||
.map(|e| Item { guid: e.guid, title: e.title, link: e.link, published: e.published, duration: e.duration, description: e.description })
|
.map(|e| Item {
|
||||||
|
guid: e.guid,
|
||||||
|
title: e.title,
|
||||||
|
link: e.link,
|
||||||
|
published: e.published,
|
||||||
|
duration: e.duration,
|
||||||
|
description: e.description,
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
self.exec(
|
self.exec(
|
||||||
"UPDATE feeds SET title = $1, image = $2, category = $3, subcategory = $4, description = $5,
|
"UPDATE feeds SET title = $1, image = $2, category = $3, subcategory = $4, description = $5,
|
||||||
@@ -447,8 +506,8 @@ impl Store {
|
|||||||
/// A feed's last error as its page says it: explained once it has been failing a day, or else
|
/// 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.
|
/// only that the check failed. Never the error itself, which is for the log.
|
||||||
fn failure_words(err: &str, failing_for: i64) -> String {
|
fn failure_words(err: &str, failing_for: i64) -> String {
|
||||||
Some(err)
|
(failing_for >= FLAG_AFTER_SECS)
|
||||||
.filter(|_| failing_for >= FLAG_AFTER_SECS)
|
.then_some(err)
|
||||||
.and_then(ipx_feed::explain_failure)
|
.and_then(ipx_feed::explain_failure)
|
||||||
.map_or_else(|| "Its last check failed.".to_owned(), |f| f.reason.to_owned())
|
.map_or_else(|| "Its last check failed.".to_owned(), |f| f.reason.to_owned())
|
||||||
}
|
}
|
||||||
@@ -512,7 +571,10 @@ pub(crate) mod tests {
|
|||||||
assert_eq!(all[0].subscribers, 3);
|
assert_eq!(all[0].subscribers, 3);
|
||||||
|
|
||||||
let i = s.instances().await.unwrap();
|
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)]);
|
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.
|
// A token taken out of the config file takes its counts.
|
||||||
s.keep_instances(&["b".into()]).await.unwrap();
|
s.keep_instances(&["b".into()]).await.unwrap();
|
||||||
@@ -554,7 +616,11 @@ pub(crate) mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_feed_naming_no_category_takes_the_one_a_server_gave_it() {
|
async fn a_feed_naming_no_category_takes_the_one_a_server_gave_it() {
|
||||||
let s = Store::scratch().await;
|
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) };
|
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();
|
s.snapshot("a", &[entry(Some("Visual Arts"))]).await.unwrap();
|
||||||
let d = s.due(0, 10).await.unwrap().remove(0);
|
let d = s.due(0, 10).await.unwrap().remove(0);
|
||||||
let mut parsed = ipx_feed::parse(include_bytes!("../../../tests/data/rss2.xml")).unwrap();
|
let mut parsed = ipx_feed::parse(include_bytes!("../../../tests/data/rss2.xml")).unwrap();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ anyhow.workspace = true
|
|||||||
atom_syndication.workspace = true
|
atom_syndication.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
opml.workspace = true
|
opml.workspace = true
|
||||||
|
pulldown-cmark.workspace = true
|
||||||
quick-xml.workspace = true
|
quick-xml.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
rss.workspace = true
|
rss.workspace = true
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ pub struct Entry {
|
|||||||
pub link: Option<String>,
|
pub link: Option<String>,
|
||||||
pub published: Option<i64>,
|
pub published: Option<i64>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
/// The item's Markdown, when the feed sends it beside HTML of its own (`<source:markdown>`):
|
||||||
|
/// the page can show either (#285). With no HTML it is the description instead (#284).
|
||||||
|
pub markdown: Option<String>,
|
||||||
pub categories: Vec<String>,
|
pub categories: Vec<String>,
|
||||||
pub explicit: bool,
|
pub explicit: bool,
|
||||||
/// Episode artwork; falls back to the feed's in the UI.
|
/// Episode artwork; falls back to the feed's in the UI.
|
||||||
@@ -111,6 +114,23 @@ pub fn redact_text(text: &str) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The most of a feed or page read. The largest feeds here are a few MB; without a limit an
|
||||||
|
/// episode's MP3 given as a feed was read whole into memory, at every check (#295).
|
||||||
|
const MAX_BODY: usize = 50 << 20;
|
||||||
|
|
||||||
|
/// A response's body, up to `MAX_BODY`: past it, an error, and the rest is never read.
|
||||||
|
async fn read_capped(mut resp: reqwest::Response) -> Result<Vec<u8>> {
|
||||||
|
if resp.content_length().is_some_and(|n| n > MAX_BODY as u64) {
|
||||||
|
return Err(anyhow!("larger than {} MB, too large to be a feed", MAX_BODY >> 20));
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while let Some(c) = resp.chunk().await? {
|
||||||
|
out.extend_from_slice(&c);
|
||||||
|
anyhow::ensure!(out.len() <= MAX_BODY, "larger than {} MB, too large to be a feed", MAX_BODY >> 20);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all, fields(url = %redact(feed_url), http.response.body.size))]
|
#[tracing::instrument(skip_all, fields(url = %redact(feed_url), http.response.body.size))]
|
||||||
pub async fn fetch(
|
pub async fn fetch(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
@@ -158,12 +178,11 @@ pub async fn fetch(
|
|||||||
// 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 =
|
||||||
resp.headers().get(&h).and_then(|v| v.to_str().ok()).map(str::to_owned)
|
|h: reqwest::header::HeaderName| 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 = read_capped(resp).await.context("reading body")?;
|
||||||
tracing::Span::current().record("http.response.body.size", bytes.len());
|
tracing::Span::current().record("http.response.body.size", bytes.len());
|
||||||
FETCHED_BYTES.fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
|
FETCHED_BYTES.fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
|
||||||
return Ok((Fetched::Body { bytes, etag, last_modified }, moved));
|
return Ok((Fetched::Body { bytes, etag, last_modified }, moved));
|
||||||
@@ -188,7 +207,10 @@ pub fn failure_kind(msg: &str) -> (String, Option<u16>) {
|
|||||||
if let Some(c) = code.filter(|c| (100..600).contains(c)) {
|
if let Some(c) = code.filter(|c| (100..600).contains(c)) {
|
||||||
return (c.to_string(), Some(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") {
|
let kind = if low.contains("dns error")
|
||||||
|
|| low.contains("failed to lookup address")
|
||||||
|
|| low.contains("no address associated")
|
||||||
|
{
|
||||||
"dns"
|
"dns"
|
||||||
} else if low.contains("too many redirects") {
|
} else if low.contains("too many redirects") {
|
||||||
"redirect_loop"
|
"redirect_loop"
|
||||||
@@ -222,14 +244,14 @@ pub fn failure_kind(msg: &str) -> (String, Option<u16>) {
|
|||||||
/// nothing extra, so a miss costs a clearer message, not a wrong one.
|
/// nothing extra, so a miss costs a clearer message, not a wrong one.
|
||||||
pub fn explain_failure(msg: &str) -> Option<Failure> {
|
pub fn explain_failure(msg: &str) -> Option<Failure> {
|
||||||
if let Some(rest) = msg.strip_prefix("got a web page, not a feed") {
|
if let Some(rest) = msg.strip_prefix("got a web page, not a feed") {
|
||||||
let new_url = rest
|
let new_url = rest.strip_prefix("; it links ").and_then(|r| r.strip_suffix(" as its feed")).map(str::to_owned);
|
||||||
.strip_prefix("; it links ")
|
|
||||||
.and_then(|r| r.strip_suffix(" as its feed"))
|
|
||||||
.map(str::to_owned);
|
|
||||||
return Some(Failure { reason: "The feed moved; this address now shows a web page.", new_url });
|
return Some(Failure { reason: "The feed moved; this address now shows a web page.", new_url });
|
||||||
}
|
}
|
||||||
if msg.contains("the site sent ") {
|
if msg.contains("the site sent ") {
|
||||||
return Some(Failure { reason: "The site sent a message instead of the feed; the publisher has to fix it.", new_url: None });
|
return Some(Failure {
|
||||||
|
reason: "The site sent a message instead of the feed; the publisher has to fix it.",
|
||||||
|
new_url: None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let low = msg.to_ascii_lowercase();
|
let low = msg.to_ascii_lowercase();
|
||||||
if low.contains("http 404") {
|
if low.contains("http 404") {
|
||||||
@@ -247,7 +269,9 @@ pub fn explain_failure(msg: &str) -> Option<Failure> {
|
|||||||
// Only a name that does not exist. A lookup that failed for the moment says nothing about the
|
// Only a name that does not exist. A lookup that failed for the moment says nothing about the
|
||||||
// site, and NPR's and Megaphone's feeds were labelled gone after a DNS outage (#143).
|
// site, and NPR's and Megaphone's feeds were labelled gone after a DNS outage (#143).
|
||||||
if !is_temporary_lookup_failure(msg)
|
if !is_temporary_lookup_failure(msg)
|
||||||
&& (low.contains("dns error") || low.contains("failed to lookup address") || low.contains("no address associated"))
|
&& (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 may be gone.", new_url: None });
|
return Some(Failure { reason: "This address no longer resolves; the site may be gone.", new_url: None });
|
||||||
}
|
}
|
||||||
@@ -276,8 +300,7 @@ pub fn is_opml(bytes: &[u8]) -> bool {
|
|||||||
/// The feeds listed in an OPML document, as (title, xml_url), walking nested folders.
|
/// The feeds listed in an OPML document, as (title, xml_url), walking nested folders.
|
||||||
pub fn parse_opml(bytes: &[u8]) -> Result<Vec<(String, String)>> {
|
pub fn parse_opml(bytes: &[u8]) -> Result<Vec<(String, String)>> {
|
||||||
let text = String::from_utf8_lossy(bytes);
|
let text = String::from_utf8_lossy(bytes);
|
||||||
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![];
|
||||||
collect_outlines(&doc.body.outlines, &mut out);
|
collect_outlines(&doc.body.outlines, &mut out);
|
||||||
Ok(out)
|
Ok(out)
|
||||||
@@ -298,10 +321,7 @@ pub fn collect_outlines(outlines: &[opml::Outline], out: &mut Vec<(String, Strin
|
|||||||
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);
|
||||||
let doc = opml::OPML::from_str(&text).ok()?;
|
let doc = opml::OPML::from_str(&text).ok()?;
|
||||||
doc.head
|
doc.head.and_then(|h| h.title).map(|t| t.trim().to_owned()).filter(|t| !t.is_empty())
|
||||||
.and_then(|h| h.title)
|
|
||||||
.map(|t| t.trim().to_owned())
|
|
||||||
.filter(|t| !t.is_empty())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The token and show of a Patreon feed link, or None for any other URL.
|
/// The token and show of a Patreon feed link, or None for any other URL.
|
||||||
@@ -351,10 +371,7 @@ pub fn same_feed(a: &str, b: &str) -> bool {
|
|||||||
/// ponytail: Patreon's own web API, undocumented, asked without signing in. If it changes,
|
/// ponytail: Patreon's own web API, undocumented, asked without signing in. If it changes,
|
||||||
/// finding shows stops and the show feeds already found keep working. The documented API
|
/// finding shows stops and the show feeds already found keep working. The documented API
|
||||||
/// needs an OAuth client per install and does not list shows.
|
/// needs an OAuth client per install and does not list shows.
|
||||||
pub async fn patreon_shows(
|
pub async fn patreon_shows(client: &reqwest::Client, url: &str) -> Result<(Option<String>, Vec<(String, String)>)> {
|
||||||
client: &reqwest::Client,
|
|
||||||
url: &str,
|
|
||||||
) -> 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).timeout(FEED_TIMEOUT).send().await.context("connecting")?;
|
let mut resp = client.get(url).timeout(FEED_TIMEOUT).send().await.context("connecting")?;
|
||||||
@@ -366,8 +383,7 @@ pub async fn patreon_shows(
|
|||||||
let Some(chunk) = resp.chunk().await.context("reading the feed")? else { break };
|
let Some(chunk) = resp.chunk().await.context("reading the feed")? else { break };
|
||||||
head.extend_from_slice(&chunk);
|
head.extend_from_slice(&chunk);
|
||||||
}
|
}
|
||||||
let campaign = patreon_campaign(&head)
|
let campaign = patreon_campaign(&head).ok_or_else(|| anyhow!("the Patreon feed does not say whose it is"))?;
|
||||||
.ok_or_else(|| anyhow!("the Patreon feed does not say whose it is"))?;
|
|
||||||
|
|
||||||
let api = format!(
|
let api = format!(
|
||||||
"https://www.patreon.com/api/campaigns/{campaign}\
|
"https://www.patreon.com/api/campaigns/{campaign}\
|
||||||
@@ -390,8 +406,11 @@ fn patreon_campaign(head: &[u8]) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A campaign's name, and its shows as (id, title).
|
||||||
|
type PatreonShows = (Option<String>, Vec<(String, String)>);
|
||||||
|
|
||||||
/// A campaign's name and its shows as (id, title), from Patreon's JSON:API answer.
|
/// A campaign's name and its shows as (id, title), from Patreon's JSON:API answer.
|
||||||
fn parse_patreon_shows(json: &[u8]) -> Result<(Option<String>, Vec<(String, String)>)> {
|
fn parse_patreon_shows(json: &[u8]) -> Result<PatreonShows> {
|
||||||
let v: serde_json::Value = serde_json::from_slice(json).context("Patreon's answer is not JSON")?;
|
let v: serde_json::Value = serde_json::from_slice(json).context("Patreon's answer is not JSON")?;
|
||||||
// Missing is not the same as none. Read as no shows, the creator feed would be scanned as
|
// Missing is not the same as none. Read as no shows, the creator feed would be scanned as
|
||||||
// a plain feed, claim every show's files, and leave the shows empty once the list returned.
|
// a plain feed, claim every show's files, and leave the shows empty once the list returned.
|
||||||
@@ -490,7 +509,8 @@ fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
|
|||||||
let tag_lower = &lower[start..end];
|
let tag_lower = &lower[start..end];
|
||||||
let is_alternate = tag_lower.contains("rel=\"alternate\"") || tag_lower.contains("rel='alternate'");
|
let is_alternate = tag_lower.contains("rel=\"alternate\"") || tag_lower.contains("rel='alternate'");
|
||||||
let is_feed_type = tag_lower.contains("rss+xml") || tag_lower.contains("atom+xml");
|
let is_feed_type = tag_lower.contains("rss+xml") || tag_lower.contains("atom+xml");
|
||||||
if is_alternate && is_feed_type
|
if is_alternate
|
||||||
|
&& is_feed_type
|
||||||
&& let Some(href) = tag_attr(tag, "href")
|
&& let Some(href) = tag_attr(tag, "href")
|
||||||
{
|
{
|
||||||
return Some(href);
|
return Some(href);
|
||||||
@@ -505,15 +525,10 @@ fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
|
|||||||
/// feed that moved (#102); cnn.com is one.
|
/// feed that moved (#102); cnn.com is one.
|
||||||
pub async fn find_feed(client: &reqwest::Client, url: &str) -> Result<String> {
|
pub async fn find_feed(client: &reqwest::Client, url: &str) -> Result<String> {
|
||||||
let read = |u: String| async move {
|
let read = |u: String| async move {
|
||||||
let resp = client
|
let resp = client.get(&u).timeout(std::time::Duration::from_secs(30)).send().await.context("connecting")?;
|
||||||
.get(&u)
|
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("connecting")?;
|
|
||||||
anyhow::ensure!(resp.status().is_success(), "HTTP {}", resp.status());
|
anyhow::ensure!(resp.status().is_success(), "HTTP {}", resp.status());
|
||||||
let base = resp.url().clone();
|
let base = resp.url().clone();
|
||||||
anyhow::Ok((base, resp.bytes().await.context("reading")?))
|
anyhow::Ok((base, read_capped(resp).await.context("reading")?))
|
||||||
};
|
};
|
||||||
let is_feed = |b: &[u8]| is_opml(b) || parse(b).is_ok();
|
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}"))?;
|
let (base, body) = read(url.to_owned()).await.with_context(|| format!("could not read {url}"))?;
|
||||||
@@ -527,7 +542,8 @@ pub async fn find_feed(client: &reqwest::Client, url: &str) -> Result<String> {
|
|||||||
let Some(href) = alternate_feed_link(&body) else {
|
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");
|
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 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"))?;
|
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");
|
anyhow::ensure!(is_feed(&feed), "{url} links {linked} as its feed, but that is not a feed either");
|
||||||
Ok(linked.into())
|
Ok(linked.into())
|
||||||
@@ -545,7 +561,7 @@ pub async fn site_icon(client: &reqwest::Client, site: &str) -> Option<String> {
|
|||||||
// The icon a page names can be gone: antirez.com names /images/favicon.png, a 404, while its
|
// 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.
|
// /favicon.ico is there. Stored unchecked, it was a broken image that was never looked up again.
|
||||||
if resp.status().is_success()
|
if resp.status().is_success()
|
||||||
&& let Ok(page) = resp.bytes().await
|
&& let Ok(page) = read_capped(resp).await
|
||||||
&& let Some(href) = page_icon(&page)
|
&& let Some(href) = page_icon(&page)
|
||||||
&& let Ok(url) = base.join(&href)
|
&& let Ok(url) = base.join(&href)
|
||||||
&& is_image(client, url.as_str()).await
|
&& is_image(client, url.as_str()).await
|
||||||
@@ -682,9 +698,7 @@ fn enclosures_by_item(bytes: &[u8]) -> Vec<Vec<Enclosure>> {
|
|||||||
let (mut url, mut mime, mut length) = (String::new(), None, None);
|
let (mut url, mut mime, mut length) = (String::new(), None, None);
|
||||||
for attr in e.attributes().flatten() {
|
for attr in e.attributes().flatten() {
|
||||||
// Values arrive escaped: a feed URL's "&" is "&" in the document.
|
// Values arrive escaped: a feed URL's "&" is "&" in the document.
|
||||||
let val = quick_xml::escape::unescape(&attr.value)
|
let val = quick_xml::escape::unescape(&attr.value).map(|v| v.trim().to_string()).unwrap_or_default();
|
||||||
.map(|v| v.trim().to_string())
|
|
||||||
.unwrap_or_default();
|
|
||||||
match attr.key.local_name().as_ref() {
|
match attr.key.local_name().as_ref() {
|
||||||
"url" => url = val,
|
"url" => url = val,
|
||||||
"type" => mime = Some(val).filter(|v| !v.is_empty()),
|
"type" => mime = Some(val).filter(|v| !v.is_empty()),
|
||||||
@@ -736,10 +750,7 @@ fn enclosures_by_item(bytes: &[u8]) -> Vec<Vec<Enclosure>> {
|
|||||||
|
|
||||||
fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
||||||
let per_item = enclosures_by_item(bytes);
|
let per_item = enclosures_by_item(bytes);
|
||||||
let explicit = ch
|
let explicit = ch.itunes_ext().and_then(|it| it.explicit()).is_some_and(is_yes);
|
||||||
.itunes_ext()
|
|
||||||
.and_then(|it| it.explicit())
|
|
||||||
.is_some_and(is_yes);
|
|
||||||
|
|
||||||
let entries = ch
|
let entries = ch
|
||||||
.items()
|
.items()
|
||||||
@@ -768,10 +779,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
item.title(),
|
item.title(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let entry_explicit = item
|
let entry_explicit = item.itunes_ext().and_then(|it| it.explicit()).is_some_and(is_yes);
|
||||||
.itunes_ext()
|
|
||||||
.and_then(|it| it.explicit())
|
|
||||||
.is_some_and(is_yes);
|
|
||||||
let it = item.itunes_ext();
|
let it = item.itunes_ext();
|
||||||
|
|
||||||
Some(Entry {
|
Some(Entry {
|
||||||
@@ -779,8 +787,11 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
title: title_text(item.title()),
|
title: title_text(item.title()),
|
||||||
link: non_empty(item.link()),
|
link: non_empty(item.link()),
|
||||||
published: item.pub_date().and_then(parse_date),
|
published: item.pub_date().and_then(parse_date),
|
||||||
// Content wins over description, as __getEntries preferred entry.content.
|
// Content wins over description, as __getEntries preferred entry.content. The
|
||||||
description: body(item.content(), item.description()),
|
// source namespace's Markdown copy only when there is neither (#284).
|
||||||
|
description: notes(item.content(), item.description(), source_markdown(item)),
|
||||||
|
markdown: non_empty(source_markdown(item))
|
||||||
|
.filter(|_| non_empty(item.content()).or_else(|| non_empty(item.description())).is_some()),
|
||||||
categories: item
|
categories: item
|
||||||
.categories()
|
.categories()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -839,25 +850,18 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
|||||||
.collect();
|
.collect();
|
||||||
drop_player_repeats(&mut enclosures);
|
drop_player_repeats(&mut enclosures);
|
||||||
|
|
||||||
let alt = e
|
let alt = e.links().iter().find(|l| l.rel() == "alternate" || l.rel().is_empty()).map(|l| l.href());
|
||||||
.links()
|
|
||||||
.iter()
|
|
||||||
.find(|l| l.rel() == "alternate" || l.rel().is_empty())
|
|
||||||
.map(|l| l.href());
|
|
||||||
|
|
||||||
let guid = pick_guid(
|
let guid =
|
||||||
Some(e.id()),
|
pick_guid(Some(e.id()), alt, enclosures.first().map(|x| x.url.as_str()), Some(e.title().as_str()))?;
|
||||||
alt,
|
|
||||||
enclosures.first().map(|x| x.url.as_str()),
|
|
||||||
Some(e.title().as_str()),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Some(Entry {
|
Some(Entry {
|
||||||
guid,
|
guid,
|
||||||
title: title_text(Some(e.title().as_str())),
|
title: title_text(Some(e.title().as_str())),
|
||||||
link: alt.map(str::to_owned),
|
link: alt.map(str::to_owned),
|
||||||
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()),
|
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()),
|
||||||
description: body(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str())),
|
description: notes(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str()), None),
|
||||||
|
markdown: None,
|
||||||
categories: e.categories().iter().map(|c| c.term().to_owned()).collect(),
|
categories: e.categories().iter().map(|c| c.term().to_owned()).collect(),
|
||||||
explicit: false,
|
explicit: false,
|
||||||
image: None,
|
image: None,
|
||||||
@@ -888,18 +892,8 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
|||||||
/// The original fell back to hashing the title or description. A guid, permalink or
|
/// The original fell back to hashing the title or description. A guid, permalink or
|
||||||
/// enclosure URL is a stable identifier already, so no hashing is needed; an entry with
|
/// enclosure URL is a stable identifier already, so no hashing is needed; an entry with
|
||||||
/// none of them has nothing to download and is dropped.
|
/// none of them has nothing to download and is dropped.
|
||||||
fn pick_guid(
|
fn pick_guid(guid: Option<&str>, link: Option<&str>, enclosure: Option<&str>, title: Option<&str>) -> Option<String> {
|
||||||
guid: Option<&str>,
|
[guid, link, enclosure, title].into_iter().flatten().map(str::trim).find(|s| !s.is_empty()).map(str::to_owned)
|
||||||
link: Option<&str>,
|
|
||||||
enclosure: Option<&str>,
|
|
||||||
title: Option<&str>,
|
|
||||||
) -> Option<String> {
|
|
||||||
[guid, link, enclosure, title]
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.map(str::trim)
|
|
||||||
.find(|s| !s.is_empty())
|
|
||||||
.map(str::to_owned)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_yes(s: &str) -> bool {
|
fn is_yes(s: &str) -> bool {
|
||||||
@@ -946,9 +940,7 @@ fn title_text(s: Option<&str>) -> Option<String> {
|
|||||||
while let Some(at) = rest.find('&') {
|
while let Some(at) = rest.find('&') {
|
||||||
out.push_str(&rest[..at]);
|
out.push_str(&rest[..at]);
|
||||||
rest = &rest[at..];
|
rest = &rest[at..];
|
||||||
let len = 1 + rest[1..]
|
let len = 1 + rest[1..].find(|c: char| !(c.is_ascii_alphanumeric() || c == '#')).unwrap_or(rest.len() - 1);
|
||||||
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '#'))
|
|
||||||
.unwrap_or(rest.len() - 1);
|
|
||||||
let decoded = rest[len..]
|
let decoded = rest[len..]
|
||||||
.starts_with(';')
|
.starts_with(';')
|
||||||
.then(|| quick_xml::escape::unescape_with(&rest[..=len], quick_xml::escape::resolve_html5_entity).ok())
|
.then(|| quick_xml::escape::unescape_with(&rest[..=len], quick_xml::escape::resolve_html5_entity).ok())
|
||||||
@@ -968,6 +960,43 @@ fn title_text(s: Option<&str>) -> Option<String> {
|
|||||||
non_empty(Some(&out))
|
non_empty(Some(&out))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An item's notes as HTML: `body`, with Markdown made HTML, whether the feed wrote its text in
|
||||||
|
/// Markdown or sent only a Markdown copy (#284). The page cleans it as it does any feed's HTML.
|
||||||
|
fn notes(content: Option<&str>, description: Option<&str>, markdown: Option<&str>) -> Option<String> {
|
||||||
|
match body(content, description) {
|
||||||
|
Some(b) if is_markdown(&b) => Some(markdown_html(&b)),
|
||||||
|
Some(b) => Some(b),
|
||||||
|
None => non_empty(markdown).map(|m| markdown_html(&m)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<source:markdown>`, from https://source.scripting.com/: the item's text in Markdown.
|
||||||
|
///
|
||||||
|
/// shortcut: found by the `source` prefix the namespace's own docs and feeds use; a feed binding
|
||||||
|
/// it to another prefix is not read, until one turns up.
|
||||||
|
fn source_markdown(item: &rss::Item) -> Option<&str> {
|
||||||
|
item.extensions().get("source")?.get("markdown")?.first()?.value()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text with no HTML tag and some Markdown: a link, a heading, a list, a quote, code or bold.
|
||||||
|
/// Plain text without any is left as it was, since rendering it would change nothing but
|
||||||
|
/// could still read a line starting "1984." as a list.
|
||||||
|
fn is_markdown(s: &str) -> bool {
|
||||||
|
let tag = s
|
||||||
|
.match_indices('<')
|
||||||
|
.any(|(i, _)| s[i + 1..].starts_with(|c: char| c.is_ascii_alphabetic() || c == '/' || c == '!'));
|
||||||
|
let block = |l: &str| ["# ", "## ", "### ", "- ", "* ", "> ", "```"].iter().any(|m| l.trim_start().starts_with(m));
|
||||||
|
!tag && (s.contains("](") || s.contains("**") || s.lines().any(block))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Markdown made HTML, uncleaned: whoever shows it cleans it as any feed's HTML.
|
||||||
|
pub fn markdown_html(md: &str) -> String {
|
||||||
|
use pulldown_cmark::{Options, Parser, html};
|
||||||
|
let mut out = String::new();
|
||||||
|
html::push_html(&mut out, Parser::new_ext(md, Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// An item's show notes: its full body when that is whole, else its description.
|
/// An item's show notes: its full body when that is whole, else its description.
|
||||||
///
|
///
|
||||||
/// libsyn served Daily Meditation Podcast's `content:encoded` cut at the `>` inside a class name
|
/// libsyn served Daily Meditation Podcast's `content:encoded` cut at the `>` inside a class name
|
||||||
@@ -1014,7 +1043,10 @@ fn text_of(html: &str) -> String {
|
|||||||
for c in html.chars() {
|
for c in html.chars() {
|
||||||
match c {
|
match c {
|
||||||
'<' => in_tag = true,
|
'<' => in_tag = true,
|
||||||
'>' if in_tag => { in_tag = false; out.push(' '); }
|
'>' if in_tag => {
|
||||||
|
in_tag = false;
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
_ if !in_tag => out.push(c),
|
_ if !in_tag => out.push(c),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -1051,14 +1083,9 @@ fn item_image(item: &rss::Item, enclosures: &[Enclosure]) -> Option<String> {
|
|||||||
non_empty(e.attrs.get("url").map(String::as_str))
|
non_empty(e.attrs.get("url").map(String::as_str))
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
attr("thumbnail", false)
|
attr("thumbnail", false).or_else(|| attr("content", true)).or_else(|| {
|
||||||
.or_else(|| attr("content", true))
|
enclosures.iter().find(|e| e.mime.as_deref().is_some_and(|m| m.starts_with("image/"))).map(|e| e.url.clone())
|
||||||
.or_else(|| {
|
})
|
||||||
enclosures
|
|
||||||
.iter()
|
|
||||||
.find(|e| e.mime.as_deref().is_some_and(|m| m.starts_with("image/")))
|
|
||||||
.map(|e| e.url.clone())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// itunes:duration is either plain seconds ("5649") or a clock ("1:34:09", "23:45").
|
/// itunes:duration is either plain seconds ("5649") or a clock ("1:34:09", "23:45").
|
||||||
@@ -1099,13 +1126,62 @@ const CATEGORIES: &[(&str, &[&str])] = &[
|
|||||||
("History", &[]),
|
("History", &[]),
|
||||||
("Health & Fitness", &["Alternative Health", "Fitness", "Medicine", "Mental Health", "Nutrition", "Sexuality"]),
|
("Health & Fitness", &["Alternative Health", "Fitness", "Medicine", "Mental Health", "Nutrition", "Sexuality"]),
|
||||||
("Kids & Family", &["Education for Kids", "Parenting", "Pets & Animals", "Stories for Kids"]),
|
("Kids & Family", &["Education for Kids", "Parenting", "Pets & Animals", "Stories for Kids"]),
|
||||||
("Leisure", &["Animation & Manga", "Automotive", "Aviation", "Crafts", "Games", "Hobbies", "Home & Garden", "Video Games"]),
|
(
|
||||||
|
"Leisure",
|
||||||
|
&["Animation & Manga", "Automotive", "Aviation", "Crafts", "Games", "Hobbies", "Home & Garden", "Video Games"],
|
||||||
|
),
|
||||||
("Music", &["Music Commentary", "Music History", "Music Interviews"]),
|
("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"]),
|
"News",
|
||||||
("Science", &["Astronomy", "Chemistry", "Earth Sciences", "Life Sciences", "Mathematics", "Natural Sciences", "Nature", "Physics", "Social Sciences"]),
|
&[
|
||||||
|
"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"]),
|
("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"]),
|
(
|
||||||
|
"Sports",
|
||||||
|
&[
|
||||||
|
"Baseball",
|
||||||
|
"Basketball",
|
||||||
|
"Cricket",
|
||||||
|
"Fantasy Sports",
|
||||||
|
"Football",
|
||||||
|
"Golf",
|
||||||
|
"Hockey",
|
||||||
|
"Rugby",
|
||||||
|
"Running",
|
||||||
|
"Soccer",
|
||||||
|
"Swimming",
|
||||||
|
"Tennis",
|
||||||
|
"Volleyball",
|
||||||
|
"Wilderness",
|
||||||
|
"Wrestling",
|
||||||
|
],
|
||||||
|
),
|
||||||
("Technology", &[]),
|
("Technology", &[]),
|
||||||
("True Crime", &[]),
|
("True Crime", &[]),
|
||||||
("TV & Film", &["After Shows", "Film History", "Film Interviews", "Film Reviews", "TV Reviews"]),
|
("TV & Film", &["After Shows", "Film History", "Film Interviews", "Film Reviews", "TV Reviews"]),
|
||||||
@@ -1120,6 +1196,11 @@ const ALIASES: &[(&str, &str)] =
|
|||||||
/// Where a category sits in Apple's list, as (category, subcategory), so the Directory browses a
|
/// 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
|
/// 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.
|
/// subcategory, and one an admin typed that is not Apple's stands as a category of its own.
|
||||||
|
/// Apple's categories as ipx has them, for the directory to check against Apple's own (#252).
|
||||||
|
pub fn categories() -> &'static [(&'static str, &'static [&'static str])] {
|
||||||
|
CATEGORIES
|
||||||
|
}
|
||||||
|
|
||||||
pub fn category_path(name: &str) -> (String, Option<String>) {
|
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.
|
// 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 = name.trim().trim_end_matches([',', ';', '.']).trim_end();
|
||||||
@@ -1135,6 +1216,9 @@ pub fn category_path(name: &str) -> (String, Option<String>) {
|
|||||||
(name.to_string(), None)
|
(name.to_string(), None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TypeSafe's System One endpoint, where Jev answers questions about a piece of state.
|
||||||
|
pub const JEV_URL: &str = "https://api.typesafe.ai/v1/systemone";
|
||||||
|
|
||||||
/// A Directory category for a feed that names none of its own, which most blogs do not (#117):
|
/// 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
|
/// 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
|
/// latest items, the subcategory being what a podcast's own category is stored as. The likeliest
|
||||||
@@ -1159,7 +1243,7 @@ pub async fn classify(client: &reqwest::Client, key: &str, feed: &ParsedFeed) ->
|
|||||||
}},
|
}},
|
||||||
});
|
});
|
||||||
let answer: serde_json::Value = client
|
let answer: serde_json::Value = client
|
||||||
.post("https://api.typesafe.ai/v1/systemone")
|
.post(JEV_URL)
|
||||||
.bearer_auth(key)
|
.bearer_auth(key)
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.timeout(FEED_TIMEOUT)
|
.timeout(FEED_TIMEOUT)
|
||||||
@@ -1199,15 +1283,12 @@ pub fn url_looks_private(feed_url: &str) -> bool {
|
|||||||
|| u.password().is_some()
|
|| u.password().is_some()
|
||||||
|| u.query_pairs().any(|(k, _)| {
|
|| u.query_pairs().any(|(k, _)| {
|
||||||
let k = k.to_ascii_lowercase();
|
let k = k.to_ascii_lowercase();
|
||||||
["auth", "token", "key", "secret", "pass", "sig", "session", "user", "uid"]
|
["auth", "token", "key", "secret", "pass", "sig", "session", "user", "uid"].iter().any(|w| k.contains(w))
|
||||||
.iter()
|
|
||||||
.any(|w| k.contains(w))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Services whose feeds are always one subscriber's own.
|
/// Services whose feeds are always one subscriber's own.
|
||||||
const PAID_HOSTS: &[&str] =
|
const PAID_HOSTS: &[&str] = &["patreon.com", "supercast.com", "supportingcast.fm", "glow.fm", "memberful.com"];
|
||||||
&["patreon.com", "supercast.com", "supportingcast.fm", "glow.fm", "memberful.com"];
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -1219,7 +1300,9 @@ mod tests {
|
|||||||
assert_eq!(super::redact("not a url?token=SECRET"), "not a url");
|
assert_eq!(super::redact("not a url?token=SECRET"), "not a url");
|
||||||
// As reqwest words a failure, and with two addresses and none.
|
// As reqwest words a failure, and with two addresses and none.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
super::redact_text("connecting: error sending request for url (https://www.patreon.com/rss/x?auth=SECRET&show=1): operation timed out"),
|
super::redact_text(
|
||||||
|
"connecting: error sending request for url (https://www.patreon.com/rss/x?auth=SECRET&show=1): operation timed out"
|
||||||
|
),
|
||||||
"connecting: error sending request for url (https://www.patreon.com/rss/x): operation timed out"
|
"connecting: error sending request for url (https://www.patreon.com/rss/x): operation timed out"
|
||||||
);
|
);
|
||||||
assert_eq!(super::redact_text("from http://a/f?k=1 to https://u:p@b/g"), "from http://a/f to https://b/g");
|
assert_eq!(super::redact_text("from http://a/f?k=1 to https://u:p@b/g"), "from http://a/f to https://b/g");
|
||||||
@@ -1258,10 +1341,18 @@ mod tests {
|
|||||||
let path = req.split_whitespace().nth(1).unwrap_or("/").to_owned();
|
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 body = "<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title></channel></rss>";
|
||||||
let resp = match path.as_str() {
|
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(),
|
"/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(),
|
"/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(),
|
"/chain" => {
|
||||||
"/loop" => "HTTP/1.1 301 Moved Permanently\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n".to_owned(),
|
"HTTP/1.1 308 Permanent Redirect\r\nLocation: /tmp\r\nContent-Length: 0\r\n\r\n".to_owned()
|
||||||
|
}
|
||||||
|
// An episode's file given as a feed: its size said, its body never sent.
|
||||||
|
"/huge" => "HTTP/1.1 200 OK\r\nContent-Length: 600000000\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()),
|
_ => format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len()),
|
||||||
};
|
};
|
||||||
let _ = sock.write_all(resp.as_bytes()).await;
|
let _ = sock.write_all(resp.as_bytes()).await;
|
||||||
@@ -1283,9 +1374,11 @@ mod tests {
|
|||||||
let (got, moved) = get("/old").await.unwrap();
|
let (got, moved) = get("/old").await.unwrap();
|
||||||
assert!(matches!(got, super::Fetched::Body { .. }));
|
assert!(matches!(got, super::Fetched::Body { .. }));
|
||||||
assert_eq!(moved, Some(format!("{base}/new")));
|
assert_eq!(moved, Some(format!("{base}/new")));
|
||||||
assert_eq!(get("/tmp").await.unwrap().1, None); // 302: for now
|
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
|
let huge = get("/huge").await.err().map(|e| format!("{e:#}")).unwrap_or_default();
|
||||||
assert_eq!(get("/new").await.unwrap().1, None); // never moved
|
assert!(huge.contains("too large to be a feed"), "{huge}");
|
||||||
|
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();
|
let looped = get("/loop").await.err().unwrap().to_string();
|
||||||
assert_eq!(super::failure_kind(&looped).0, "redirect_loop");
|
assert_eq!(super::failure_kind(&looped).0, "redirect_loop");
|
||||||
}
|
}
|
||||||
@@ -1319,10 +1412,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert!(feed.entries[1].explicit, "entry-level itunes:explicit=yes");
|
assert!(feed.entries[1].explicit, "entry-level itunes:explicit=yes");
|
||||||
assert_eq!(
|
assert_eq!(feed.entries[2].enclosures[0].mime.as_deref(), Some("application/x-bittorrent"));
|
||||||
feed.entries[2].enclosures[0].mime.as_deref(),
|
|
||||||
Some("application/x-bittorrent")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1336,6 +1426,40 @@ mod tests {
|
|||||||
assert_eq!(parse(atom).unwrap().description.as_deref(), Some("Or here."));
|
assert_eq!(parse(atom).unwrap().description.as_deref(), Some("Or here."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn markdown_notes_are_made_html_and_html_is_left_alone() {
|
||||||
|
let feed = |items: &str| {
|
||||||
|
let xml = format!(
|
||||||
|
r#"<?xml version="1.0"?><rss version="2.0" xmlns:source="https://source.scripting.com/"><channel><title>R</title>{items}</channel></rss>"#
|
||||||
|
);
|
||||||
|
parse(xml.as_bytes()).unwrap().entries.into_iter().map(|e| e.description).collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
let got = feed(
|
||||||
|
r#"<item><guid>a</guid><description># Hi
|
||||||
|
|
||||||
|
Some **bold** and [a link](https://x.example/).</description></item>
|
||||||
|
<item><guid>b</guid><source:markdown>Only *Markdown* here.</source:markdown></item>
|
||||||
|
<item><guid>c</guid><description><p>HTML **as sent**</p></description><source:markdown>ignored</source:markdown></item>
|
||||||
|
<item><guid>d</guid><description>1984. Plain text, left as it is.</description></item>"#,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
got[0].as_deref(),
|
||||||
|
Some("<h1>Hi</h1>\n<p>Some <strong>bold</strong> and <a href=\"https://x.example/\">a link</a>.</p>\n")
|
||||||
|
);
|
||||||
|
assert_eq!(got[1].as_deref(), Some("<p>Only <em>Markdown</em> here.</p>\n"), "the Markdown copy, alone");
|
||||||
|
assert_eq!(got[2].as_deref(), Some("<p>HTML **as sent**</p>"), "HTML wins, untouched");
|
||||||
|
assert_eq!(got[3].as_deref(), Some("1984. Plain text, left as it is."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn markdown_sent_beside_html_is_kept_for_the_page_to_switch_to() {
|
||||||
|
let xml = br#"<?xml version="1.0"?><rss version="2.0" xmlns:source="https://source.scripting.com/"><channel><title>R</title>
|
||||||
|
<item><guid>both</guid><description><p>Hi</p></description><source:markdown>*Hi*</source:markdown></item>
|
||||||
|
<item><guid>alone</guid><source:markdown>*Hi*</source:markdown></item></channel></rss>"#;
|
||||||
|
let got: Vec<_> = parse(xml).unwrap().entries.into_iter().map(|e| e.markdown).collect();
|
||||||
|
assert_eq!(got, [Some("*Hi*".to_owned()), None], "only beside HTML; alone, it is the description (#284)");
|
||||||
|
}
|
||||||
|
|
||||||
#[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>
|
||||||
@@ -1344,9 +1468,12 @@ mod tests {
|
|||||||
<enclosure url="https://x/ep.mp3?_=2" length="1" type="audio/mpeg"/>
|
<enclosure url="https://x/ep.mp3?_=2" length="1" type="audio/mpeg"/>
|
||||||
<enclosure url="https://x/other.mp3?_=3&key=k" length="1" type="audio/mpeg"/>
|
<enclosure url="https://x/other.mp3?_=3&key=k" length="1" type="audio/mpeg"/>
|
||||||
</item></channel></rss>"#;
|
</item></channel></rss>"#;
|
||||||
let urls: Vec<String> =
|
let urls: Vec<String> = parse(xml).unwrap().entries[0].enclosures.iter().map(|e| e.url.clone()).collect();
|
||||||
parse(xml).unwrap().entries[0].enclosures.iter().map(|e| e.url.clone()).collect();
|
assert_eq!(
|
||||||
assert_eq!(urls, ["https://x/ep.mp3", "https://x/other.mp3?_=3&key=k"], "the repeat goes, a different file stays");
|
urls,
|
||||||
|
["https://x/ep.mp3", "https://x/other.mp3?_=3&key=k"],
|
||||||
|
"the repeat goes, a different file stays"
|
||||||
|
);
|
||||||
assert_eq!(same_file_key("https://x/a.mp3?key=k&_=2"), same_file_key("https://x/a.mp3?key=k"));
|
assert_eq!(same_file_key("https://x/a.mp3?key=k&_=2"), same_file_key("https://x/a.mp3?key=k"));
|
||||||
assert_ne!(same_file_key("https://x/a.mp3?_=x"), same_file_key("https://x/a.mp3"), "only a number");
|
assert_ne!(same_file_key("https://x/a.mp3?_=x"), same_file_key("https://x/a.mp3"), "only a number");
|
||||||
}
|
}
|
||||||
@@ -1373,8 +1500,15 @@ mod tests {
|
|||||||
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
|
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
|
||||||
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
|
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
|
||||||
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
|
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
|
||||||
assert_eq!(body(Some("<p>Notes</p>"), Some("<p>Notes</p>")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
|
assert_eq!(
|
||||||
assert_eq!(body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).as_deref(), Some("Plain notes, no tags."));
|
body(Some("<p>Notes</p>"), Some("<p>Notes</p>")).as_deref(),
|
||||||
|
Some("<p>Notes</p>"),
|
||||||
|
"a whole body wins"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).as_deref(),
|
||||||
|
Some("Plain notes, no tags.")
|
||||||
|
);
|
||||||
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
|
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
|
||||||
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
|
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
|
||||||
}
|
}
|
||||||
@@ -1393,14 +1527,19 @@ mod tests {
|
|||||||
);
|
);
|
||||||
// A podcast repeating its notes, whole, cut short, or differently spaced: shown once.
|
// A podcast repeating its notes, whole, cut short, or differently spaced: shown once.
|
||||||
let notes = "<p>This week we talk about <a href=\"x\">tape</a>, drums and a very long list.</p>";
|
let notes = "<p>This week we talk about <a href=\"x\">tape</a>, drums and a very long list.</p>";
|
||||||
for d in ["This week we talk about tape, drums and a very long list.",
|
for d in [
|
||||||
"This week we talk about tape, drums…",
|
"This week we talk about tape, drums and a very long list.",
|
||||||
"This week we talk about\ntape [...]"] {
|
"This week we talk about tape, drums…",
|
||||||
|
"This week we talk about\ntape [...]",
|
||||||
|
] {
|
||||||
assert_eq!(body(Some(notes), Some(d)).as_deref(), Some(notes), "{d:?} is already in the body");
|
assert_eq!(body(Some(notes), Some(d)).as_deref(), Some(notes), "{d:?} is already in the body");
|
||||||
}
|
}
|
||||||
let long = "word ".repeat(80);
|
let long = "word ".repeat(80);
|
||||||
assert_eq!(body(Some("<p>The post.</p>"), Some(&long)).as_deref(), Some("<p>The post.</p>"),
|
assert_eq!(
|
||||||
"a long description is notes, not a subtitle");
|
body(Some("<p>The post.</p>"), Some(&long)).as_deref(),
|
||||||
|
Some("<p>The post.</p>"),
|
||||||
|
"a long description is notes, not a subtitle"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1413,10 +1552,7 @@ mod tests {
|
|||||||
<enclosure url="https://x/a.mp3" length="1" type="audio/mpeg"/></item>
|
<enclosure url="https://x/a.mp3" length="1" type="audio/mpeg"/></item>
|
||||||
</channel></rss>"#;
|
</channel></rss>"#;
|
||||||
let feed = parse(xml).unwrap();
|
let feed = parse(xml).unwrap();
|
||||||
assert!(
|
assert!(feed.entries[0].explicit, "the entry says nothing; the feed-level flag must still mark it explicit");
|
||||||
feed.entries[0].explicit,
|
|
||||||
"the entry says nothing; the feed-level flag must still mark it explicit"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1446,13 +1582,17 @@ mod tests {
|
|||||||
explain_failure("the feed's address answered with nothing at all").unwrap().reason,
|
explain_failure("the feed's address answered with nothing at all").unwrap().reason,
|
||||||
"This address answers with nothing; the site may be gone."
|
"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();
|
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.");
|
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).
|
// 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";
|
let blip = "connecting: dns error: failed to lookup address information: Temporary failure in name resolution";
|
||||||
assert!(is_temporary_lookup_failure(blip));
|
assert!(is_temporary_lookup_failure(blip));
|
||||||
assert!(explain_failure(blip).is_none());
|
assert!(explain_failure(blip).is_none());
|
||||||
assert!(!is_temporary_lookup_failure("connecting: dns error: failed to lookup address information: Name or service not known"));
|
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());
|
||||||
@@ -1663,10 +1803,7 @@ mod tests {
|
|||||||
|
|
||||||
let two = &f.entries[0].enclosures;
|
let two = &f.entries[0].enclosures;
|
||||||
assert_eq!(two.len(), 2, "both enclosures survive");
|
assert_eq!(two.len(), 2, "both enclosures survive");
|
||||||
assert_eq!(
|
assert_eq!(two[0].url, "https://x/a.mp3?v=1&t=2", "document order, and the escaped ampersand is decoded");
|
||||||
two[0].url, "https://x/a.mp3?v=1&t=2",
|
|
||||||
"document order, and the escaped ampersand is decoded"
|
|
||||||
);
|
|
||||||
assert_eq!(two[0].length, Some(111));
|
assert_eq!(two[0].length, Some(111));
|
||||||
assert_eq!(two[1].url, "https://x/b.mp4");
|
assert_eq!(two[1].url, "https://x/b.mp4");
|
||||||
assert_eq!(two[1].mime.as_deref(), Some("video/mp4"));
|
assert_eq!(two[1].mime.as_deref(), Some("video/mp4"));
|
||||||
@@ -1769,4 +1906,3 @@ mod tests {
|
|||||||
assert_eq!(category_path("Software How-To"), ("Technology".into(), None));
|
assert_eq!(category_path("Software How-To"), ("Technology".into(), None));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-10-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- An item whose feed sends Markdown beside its HTML, as Scripting News does with `<source:markdown>`, keeps it: `markdown` and `markdown_html` in the API's items (#285).
|
||||||
|
- An item written in Markdown shows formatted, not as Markdown's raw characters: a description in Markdown, or, with no other text, RSS's `<source:markdown>`. HTML a feed sends is left as it is (#284).
|
||||||
|
- A show from an OPML subscription or a Patreon creator can be kept: `kept` on `PATCH /api/feeds/{id}` and in the feed list. When the OPML drops a kept show, or you leave the OPML, it stays on your list as a feed of its own (#280).
|
||||||
|
- `/metrics` counts Signals: items judged and fitting, calls to Jev by how they ended, the time they took and the tokens TypeSafe counted, by person and by whose key paid so the server's bill can be split, time since the judge's last pass, and keys refused; a failed call to Jev shows as a failed trace (#270, #275, #278).
|
||||||
|
- An admin setting for how many days back a new Signal reads (#276).
|
||||||
|
- A list's trace says which filter, sort and page it was asked for, and whether a search, so a slow list can be told from the others (#287).
|
||||||
|
- A feed, an OPML subscription or a Patreon creator can be left out of All Subscriptions: `hide_from_all` on `PATCH /api/feeds/{id}` and in the feed list. Set on an OPML or creator it covers every show in it; Signals and Currently Listening still read them (#288).
|
||||||
|
- A feed, an OPML subscription or a Patreon creator can be kept out of Signals: `hide_from_signals` on `PATCH /api/feeds/{id}` and in the feed list. Jev is not asked about its items, so they cost nothing, and they are left out of each Signal's list and counts (#289).
|
||||||
|
- An admin setting, Signals for everyone on the server's TypeSafe key, so the server pays for Jev instead of each person (#271).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `GET /api/feeds/{id}/entries` for an OPML subscription or a Patreon creator lists the items of the shows in it that you subscribe to, together, where it listed nothing (#283).
|
||||||
|
- A TypeSafe key is checked with TypeSafe before it is saved, and one it refuses is not saved (#271).
|
||||||
|
- A new Signal reads the last week instead of the last 30 days, unless an admin sets otherwise (#276).
|
||||||
|
- Editing a Signal's name, words or description asks again only about what it had picked out, and about new items as they come, instead of the whole window again (#276).
|
||||||
|
- Signals fill faster and cost less: up to ten calls to TypeSafe at once instead of one, paced under TypeSafe's limits of 80 calls and 100,000 input tokens a second, with every call held back while TypeSafe answers 429 (#277), everyone's Signals on an item in one call on the server's key, and an item's text cut at 1,000 characters instead of 2,000 (#275).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Artwork comes quickly: an image already kept is served without asking the database again, and the database finds which feed names an image by an index instead of reading every item, which took over half a second an image (#272).
|
||||||
|
- The Directory shows the artwork of podcasts nobody on this server subscribes to, where it drew their initials (#273).
|
||||||
|
- Artwork a publisher sends without an image type, or as `application/octet-stream`, is shown when it is a JPEG, PNG, GIF or WebP, instead of initials (#279).
|
||||||
|
- Looking for items to read for Signals read every item on the server on each scan; it now reads only recent ones (#274).
|
||||||
|
- With the downloads folder out of reach during a clean-up, as when the array stops, every downloaded episode was taken as deleted by hand and forgotten; the clean-up now leaves them alone until the folder is back (#293).
|
||||||
|
- A feed address that serves something larger than 50 MB, such as an episode's file given as a feed, is refused as too large, instead of read whole into memory at every check (#295).
|
||||||
|
- Searching items for `%` or `_` finds those characters, instead of treating them as wildcards that matched nearly everything (#296).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- ammonia, which cleans publishers' HTML, updated to 4.2.4 for three advisories, among them XSS through SVG animation tags; rustls to 0.23.46 for five and h2 to 0.4.20 for one, in every connection ipx makes (#281).
|
||||||
|
- The address of a file being downloaded, which for Patreon carries its signature, is sent to open pages and written to the log without its query, as other addresses already were (#292).
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-10-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Signals: a feed of your own, picked out of the noise of everything you subscribe to, holding the items TypeSafe's Jev says are about the subject you give it: a name, words, and a sentence of your own if they are not enough, from the last 30 days and every new one, judged on your own TypeSafe key, each with an icon of its own if you like, which Jev can pick on the server's TypeSafe key (`POST /api/signals/icon`) (`/api/signals`, and `typesafe_key` on `/api/me`) (#254).
|
||||||
|
- With `OTEL_EXPORTER_OTLP_COMPRESSION=gzip`, traces go to the collector compressed; `/metrics` answers gzipped, and a snapshot goes to the directory gzipped (#253).
|
||||||
|
- A Directory request's trace shows the call to the directory whole, with the size of its answer, and the steps that match it against your subscriptions (#250).
|
||||||
|
- The page's report of how long a list took to show after a click (`list_shown`, with the view and filter) is logged under that click's trace (#246).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- A feed that sends the same body again, as Patreon and Substack do whatever they are asked, is taken as unchanged instead of read again, and, without a schedule of its own, checked less often the longer it stays the same, up to every four hours, back on schedule when it changes (#242).
|
||||||
|
- Feeds are fetched accepting brotli and zstd as well as gzip (#244).
|
||||||
|
- On Postgres the daemon keeps all twenty of its database connections open, so a page load no longer waits for new ones, 80-330 ms each: the feed list and item lists took 0.3-1.6 s now and then, where their queries take 5-30 ms (#241).
|
||||||
|
- Currently Listening and the Pinned filter answer in a few milliseconds: they read every item on the server, 1.3 s for Currently Listening (#249).
|
||||||
|
|
||||||
## [1.2.0] - 2026-10-15
|
## [1.2.0] - 2026-10-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -832,7 +885,9 @@ 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/v1.2.0...main
|
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.4.0...main
|
||||||
|
[1.4.0]: https://git.sdf1.net/rays/ipx/compare/v1.3.0...v1.4.0
|
||||||
|
[1.3.0]: https://git.sdf1.net/rays/ipx/compare/v1.2.0...v1.3.0
|
||||||
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/v1.1.0...v1.2.0
|
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/v1.1.0...v1.2.0
|
||||||
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/v1.0.0...v1.1.0
|
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/v1.0.0...v1.1.0
|
||||||
[1.0.0]: https://git.sdf1.net/rays/ipx/compare/v0.12.0...v1.0.0
|
[1.0.0]: https://git.sdf1.net/rays/ipx/compare/v0.12.0...v1.0.0
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "1.2.0"
|
version = "1.4.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ipx-feed.workspace = true
|
ipx-feed.workspace = true
|
||||||
@@ -9,7 +10,7 @@ ipx-directory.workspace = true
|
|||||||
ammonia.workspace = true
|
ammonia.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
argon2.workspace = true
|
argon2.workspace = true
|
||||||
atom_syndication.workspace = true
|
flate2.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
@@ -19,9 +20,7 @@ librqbit.workspace = true
|
|||||||
opentelemetry.workspace = true
|
opentelemetry.workspace = true
|
||||||
opml.workspace = true
|
opml.workspace = true
|
||||||
percent-encoding.workspace = true
|
percent-encoding.workspace = true
|
||||||
quick-xml.workspace = true
|
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
rss.workspace = true
|
|
||||||
sea-orm.workspace = true
|
sea-orm.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|||||||
@@ -103,11 +103,8 @@ impl Keys {
|
|||||||
|
|
||||||
/// Replaces the whole set, so a key Cloudflare has retired stops being accepted.
|
/// Replaces the whole set, so a key Cloudflare has retired stops being accepted.
|
||||||
fn store(&self, set: JwkSet) {
|
fn store(&self, set: JwkSet) {
|
||||||
let keys = set
|
let keys =
|
||||||
.keys
|
set.keys.iter().filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?))).collect();
|
||||||
.iter()
|
|
||||||
.filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?)))
|
|
||||||
.collect();
|
|
||||||
let mut c = self.cache.lock().unwrap();
|
let mut c = self.cache.lock().unwrap();
|
||||||
c.keys = keys;
|
c.keys = keys;
|
||||||
c.fetched = Some(Instant::now());
|
c.fetched = Some(Instant::now());
|
||||||
@@ -181,8 +178,7 @@ mod tests {
|
|||||||
// HMAC and none, the classic ways to get a token past a verifier that trusts its header.
|
// HMAC and none, the classic ways to get a token past a verifier that trusts its header.
|
||||||
let hs = token(b"any secret at all", Algorithm::HS256, claims(AUD, 300));
|
let hs = token(b"any secret at all", Algorithm::HS256, claims(AUD, 300));
|
||||||
assert_eq!(check(&keys, &hs).await, None, "HS256");
|
assert_eq!(check(&keys, &hs).await, None, "HS256");
|
||||||
let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ",
|
let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ", ok.split('.').nth(1).unwrap());
|
||||||
ok.split('.').nth(1).unwrap());
|
|
||||||
assert_eq!(check(&keys, &none).await, None, "alg none");
|
assert_eq!(check(&keys, &none).await, None, "alg none");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,16 +187,25 @@ mod tests {
|
|||||||
let keys = Keys::default();
|
let keys = Keys::default();
|
||||||
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||||
let fetches = std::sync::atomic::AtomicUsize::new(0);
|
let fetches = std::sync::atomic::AtomicUsize::new(0);
|
||||||
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
|
let count = || {
|
||||||
assert_eq!(keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(), Some("rays@sdf1.net"),
|
fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
"a key not cached yet is fetched");
|
async { Ok(jwks()) }
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(),
|
||||||
|
Some("rays@sdf1.net"),
|
||||||
|
"a key not cached yet is fetched"
|
||||||
|
);
|
||||||
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1);
|
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||||
|
|
||||||
// A made-up key id straight after: not fetched again.
|
// A made-up key id straight after: not fetched again.
|
||||||
let mut h = Header::new(Algorithm::RS256);
|
let mut h = Header::new(Algorithm::RS256);
|
||||||
h.kid = Some("nobody".into());
|
h.kid = Some("nobody".into());
|
||||||
let stray = encode(&h, &claims(AUD, 300), &EncodingKey::from_rsa_der(SIGNER)).unwrap();
|
let stray = encode(&h, &claims(AUD, 300), &EncodingKey::from_rsa_der(SIGNER)).unwrap();
|
||||||
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
|
let count = || {
|
||||||
|
fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
async { Ok(jwks()) }
|
||||||
|
};
|
||||||
assert_eq!(keys.verify_with(TEAM, AUD, &stray, count).await, None);
|
assert_eq!(keys.verify_with(TEAM, AUD, &stray, count).await, None);
|
||||||
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1, "rate-limited");
|
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1, "rate-limited");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Artwork kept on disk, so the page loads it from iPX: fast after the first time, nothing asked
|
//! 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.
|
//! 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::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{LazyLock, Mutex};
|
use std::sync::{LazyLock, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ pub fn verify_password(password: &str, stored: &str) -> bool {
|
|||||||
tracing::warn!("stored password hash is unreadable; refusing the sign-in");
|
tracing::warn!("stored password hash is unreadable; refusing the sign-in");
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
Argon2::default()
|
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
|
||||||
.verify_password(password.as_bytes(), &parsed)
|
|
||||||
.is_ok()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many password checks run at once: each is tens of milliseconds of CPU, and forty wrong
|
/// How many password checks run at once: each is tens of milliseconds of CPU, and forty wrong
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ pub struct General {
|
|||||||
pub directory_token: String,
|
pub directory_token: String,
|
||||||
/// Send the directory the public feeds people here subscribe to, and how many of them do.
|
/// Send the directory the public feeds people here subscribe to, and how many of them do.
|
||||||
pub directory_share: bool,
|
pub directory_share: bool,
|
||||||
|
/// Signals are judged on the server's TypeSafe key (`TYPESAFE_KEY`), for everyone, rather than
|
||||||
|
/// on each person's own (#271). Without that key it changes nothing.
|
||||||
|
pub signals_on_server: bool,
|
||||||
|
/// How far back a new Signal looks, in days (#276): each item is a call to TypeSafe, and a
|
||||||
|
/// month of production's feeds was about 8,000.
|
||||||
|
pub signal_days: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
@@ -207,19 +213,15 @@ impl Default for General {
|
|||||||
directory_url: String::new(),
|
directory_url: String::new(),
|
||||||
directory_token: String::new(),
|
directory_token: String::new(),
|
||||||
directory_share: false,
|
directory_share: false,
|
||||||
|
signals_on_server: false,
|
||||||
|
signal_days: 7,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Torrent {
|
impl Default for Torrent {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self { enabled: true, seed_ratio: 1.0, seed_time_mins: 60, port_range: "6881-6889".into(), stall_mins: 30 }
|
||||||
enabled: true,
|
|
||||||
seed_ratio: 1.0,
|
|
||||||
seed_time_mins: 60,
|
|
||||||
port_range: "6881-6889".into(),
|
|
||||||
stall_mins: 30,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,14 +303,11 @@ impl Config {
|
|||||||
// ponytail: a missing config is an empty one, so `list`/`add` work on a fresh install.
|
// ponytail: a missing config is an empty one, so `list`/`add` work on a fresh install.
|
||||||
return Ok(Self::default());
|
return Ok(Self::default());
|
||||||
}
|
}
|
||||||
let text = std::fs::read_to_string(path)
|
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||||
.with_context(|| format!("reading {}", path.display()))?;
|
let mut cfg: Self = toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
|
||||||
let mut cfg: Self =
|
|
||||||
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
|
|
||||||
cfg.general.download_dir = expand_tilde(&cfg.general.download_dir);
|
cfg.general.download_dir = expand_tilde(&cfg.general.download_dir);
|
||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the database keeps of the configuration (issue #18): the server settings the admin page
|
/// What the database keeps of the configuration (issue #18): the server settings the admin page
|
||||||
@@ -331,6 +330,14 @@ pub struct Stored {
|
|||||||
pub directory_token: String,
|
pub directory_token: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub directory_share: bool,
|
pub directory_share: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub signals_on_server: bool,
|
||||||
|
#[serde(default = "signal_days")]
|
||||||
|
pub signal_days: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signal_days() -> u64 {
|
||||||
|
General::default().signal_days
|
||||||
}
|
}
|
||||||
|
|
||||||
fn art_cache_mb() -> u64 {
|
fn art_cache_mb() -> u64 {
|
||||||
@@ -338,7 +345,7 @@ fn art_cache_mb() -> u64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `[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; 9] = [
|
const STORED_KEYS: [&str; 11] = [
|
||||||
"schedule",
|
"schedule",
|
||||||
"max_total_gb",
|
"max_total_gb",
|
||||||
"max_age_days",
|
"max_age_days",
|
||||||
@@ -348,6 +355,8 @@ const STORED_KEYS: [&str; 9] = [
|
|||||||
"directory_url",
|
"directory_url",
|
||||||
"directory_token",
|
"directory_token",
|
||||||
"directory_share",
|
"directory_share",
|
||||||
|
"signals_on_server",
|
||||||
|
"signal_days",
|
||||||
];
|
];
|
||||||
|
|
||||||
impl Stored {
|
impl Stored {
|
||||||
@@ -363,6 +372,8 @@ impl Stored {
|
|||||||
directory_url: g.directory_url.clone(),
|
directory_url: g.directory_url.clone(),
|
||||||
directory_token: g.directory_token.clone(),
|
directory_token: g.directory_token.clone(),
|
||||||
directory_share: g.directory_share,
|
directory_share: g.directory_share,
|
||||||
|
signals_on_server: g.signals_on_server,
|
||||||
|
signal_days: g.signal_days,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,6 +388,8 @@ impl Stored {
|
|||||||
g.directory_url = self.directory_url;
|
g.directory_url = self.directory_url;
|
||||||
g.directory_token = self.directory_token;
|
g.directory_token = self.directory_token;
|
||||||
g.directory_share = self.directory_share;
|
g.directory_share = self.directory_share;
|
||||||
|
g.signals_on_server = self.signals_on_server;
|
||||||
|
g.signal_days = self.signal_days;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,10 +506,7 @@ pub fn unique_slug(text: &str, taken: &BTreeMap<String, Feed>) -> String {
|
|||||||
|
|
||||||
/// `$var`, or `~/fallback` when it is unset or empty, as the XDG base directory spec says.
|
/// `$var`, or `~/fallback` when it is unset or empty, as the XDG base directory spec says.
|
||||||
fn xdg(var: &str, fallback: &str) -> PathBuf {
|
fn xdg(var: &str, fallback: &str) -> PathBuf {
|
||||||
std::env::var_os(var)
|
std::env::var_os(var).filter(|v| !v.is_empty()).map(PathBuf::from).unwrap_or_else(|| home().join(fallback))
|
||||||
.filter(|v| !v.is_empty())
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|| home().join(fallback))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn home() -> PathBuf {
|
fn home() -> PathBuf {
|
||||||
@@ -575,10 +585,20 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn intervals_parse_from_the_forms_people_actually_type() {
|
fn intervals_parse_from_the_forms_people_actually_type() {
|
||||||
for (input, want) in [
|
for (input, want) in [
|
||||||
("every 30m", 30), ("30m", 30), ("30", 30), ("every 30 minutes", 30),
|
("every 30m", 30),
|
||||||
("every 4h", 240), ("4h", 240), ("4 hours", 240), ("EVERY 4H", 240),
|
("30m", 30),
|
||||||
("1d", 1440), ("every 2 days", 2880), (" every 90m ", 90),
|
("30", 30),
|
||||||
("1w", 10080), ("every 2 weeks", 20160), ("2 w", 20160),
|
("every 30 minutes", 30),
|
||||||
|
("every 4h", 240),
|
||||||
|
("4h", 240),
|
||||||
|
("4 hours", 240),
|
||||||
|
("EVERY 4H", 240),
|
||||||
|
("1d", 1440),
|
||||||
|
("every 2 days", 2880),
|
||||||
|
(" every 90m ", 90),
|
||||||
|
("1w", 10080),
|
||||||
|
("every 2 weeks", 20160),
|
||||||
|
("2 w", 20160),
|
||||||
] {
|
] {
|
||||||
assert_eq!(parse_interval(input), Some(want), "{input:?}");
|
assert_eq!(parse_interval(input), Some(want), "{input:?}");
|
||||||
}
|
}
|
||||||
@@ -650,11 +670,26 @@ mod tests {
|
|||||||
assert!(slug(&"x".repeat(100)).len() <= 40);
|
assert!(slug(&"x".repeat(100)).len() <= 40);
|
||||||
|
|
||||||
let mut taken = BTreeMap::new();
|
let mut taken = BTreeMap::new();
|
||||||
taken.insert("the-daily".to_string(), Feed {
|
taken.insert(
|
||||||
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
"the-daily".to_string(),
|
||||||
auto_download: true, max_new_per_check: None, username: None,
|
Feed {
|
||||||
password: None, password_env: None, category: None, listed: false, private: 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,
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1159
crates/ipx/src/db.rs
@@ -164,8 +164,7 @@ pub fn pieces(frames: &[(u64, u64)], drop: &[(u64, u64)], frame: f64) -> Vec<(u6
|
|||||||
}
|
}
|
||||||
let dropped = drops.peek().is_some_and(|&&(a, _)| a as usize <= n);
|
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.
|
// Something between two frames (an ID3 tag partway through) ends a piece too.
|
||||||
let joins = !dropped
|
let joins = !dropped && run.is_some_and(|(f, c)| c < most && frames[f + c - 1].0 + frames[f + c - 1].1 == pos);
|
||||||
&& 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() {
|
if !joins && let Some(r) = run.take() {
|
||||||
push(&mut out, r);
|
push(&mut out, r);
|
||||||
}
|
}
|
||||||
@@ -261,9 +260,7 @@ impl Wanting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn wants(&self, user: i64, feed: &str) -> bool {
|
pub fn wants(&self, user: i64, feed: &str) -> bool {
|
||||||
self.people
|
self.people.get(&user).is_some_and(|p| wants(p, feed, self.groups.get(feed).map(String::as_str)))
|
||||||
.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.
|
/// The pause this person has DeadAir leave in a feed: the feed's, else its group's.
|
||||||
@@ -415,14 +412,15 @@ async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet<i64>) -> Result<
|
|||||||
let todo = ctx.db.deadair_todo().await?;
|
let todo = ctx.db.deadair_todo().await?;
|
||||||
// For /metrics (#195): files of feeds someone has DeadAir on, from memory, without the
|
// For /metrics (#195): files of feeds someone has DeadAir on, from memory, without the
|
||||||
// per-file subscription and finished checks below.
|
// 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));
|
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);
|
st.deadair_backlog.store(todo.iter().filter(|t| wanted(t)).count() as i64, Relaxed);
|
||||||
for t in todo {
|
for t in todo {
|
||||||
if failed.contains(&t.id) {
|
if failed.contains(&t.id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let users: Vec<i64> =
|
let users: Vec<i64> = wanting.people.keys().copied().filter(|&u| wanting.wants(u, &t.feed_id)).collect();
|
||||||
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? {
|
if users.is_empty() || !ctx.db.subscribed_any(&users, &t.feed_id).await? {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -446,11 +444,18 @@ async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet<i64>) -> Result<
|
|||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
ctx.db.store_plans(t.id, &serde_json::to_string(&plans)?).instrument(span.in_scope(|| step("store_plans"))).await?;
|
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);
|
let usual = plans.get(&DEFAULT_PAUSE);
|
||||||
span.record("cuts", usual.map_or(0, |p| p.cuts.len()));
|
span.record("cuts", usual.map_or(0, |p| p.cuts.len()));
|
||||||
let saved_ms = usual.map_or(0, Plan::saved_ms);
|
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 });
|
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.
|
// The event says it too (ev="deadair_done"); this line has how long it took and how many cuts.
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
feed = t.feed_id,
|
feed = t.feed_id,
|
||||||
@@ -472,8 +477,17 @@ 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 Some((first, frame)) = probe(src).instrument(step("probe")).await? else { return Ok(None) };
|
||||||
let path = src.display().to_string();
|
let path = src.display().to_string();
|
||||||
let log = ffmpeg(&[
|
let log = ffmpeg(&[
|
||||||
"-hide_banner", "-nostats", "-i", &path, "-map", "0:a:0",
|
"-hide_banner",
|
||||||
"-af", &format!("silencedetect=n={THRESHOLD}:d={MIN_SILENCE}"), "-f", "null", "-",
|
"-nostats",
|
||||||
|
"-i",
|
||||||
|
&path,
|
||||||
|
"-map",
|
||||||
|
"0:a:0",
|
||||||
|
"-af",
|
||||||
|
&format!("silencedetect=n={THRESHOLD}:d={MIN_SILENCE}"),
|
||||||
|
"-f",
|
||||||
|
"null",
|
||||||
|
"-",
|
||||||
])
|
])
|
||||||
.instrument(step("silencedetect"))
|
.instrument(step("silencedetect"))
|
||||||
.await
|
.await
|
||||||
@@ -492,16 +506,15 @@ pub async fn analyse(src: &Path) -> Result<Option<BTreeMap<i64, Plan>>> {
|
|||||||
|
|
||||||
/// Where each audio frame is in the file, as (byte offset, bytes), in order.
|
/// Where each audio frame is in the file, as (byte offset, bytes), in order.
|
||||||
async fn frame_bytes(path: &str) -> Result<Vec<(u64, u64)>> {
|
async fn frame_bytes(path: &str) -> Result<Vec<(u64, u64)>> {
|
||||||
let out = run_out("ffprobe", &[
|
let out = run_out(
|
||||||
"-v", "error", "-select_streams", "a:0", "-show_entries", "packet=pos,size", "-of", "compact=p=0", path,
|
"ffprobe",
|
||||||
])
|
&["-v", "error", "-select_streams", "a:0", "-show_entries", "packet=pos,size", "-of", "compact=p=0", path],
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
// ffprobe writes the fields in its own order, not the order asked for: by name, then.
|
// ffprobe writes the fields in its own order, not the order asked for: by name, then.
|
||||||
out.lines()
|
out.lines()
|
||||||
.map(|l| {
|
.map(|l| {
|
||||||
let field = |k: &str| {
|
let field = |k: &str| l.split('|').find_map(|f| f.strip_prefix(k)?.strip_prefix('=')?.parse::<u64>().ok());
|
||||||
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}"))
|
field("pos").zip(field("size")).with_context(|| format!("reading ffprobe's frame: {l}"))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -511,26 +524,44 @@ async fn frame_bytes(path: &str) -> Result<Vec<(u64, u64)>> {
|
|||||||
/// can shorten; None for one it cannot. A cover picture in an MP3 is not video.
|
/// 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)>> {
|
async fn probe(src: &Path) -> Result<Option<(f64, f64)>> {
|
||||||
let path = src.display().to_string();
|
let path = src.display().to_string();
|
||||||
let out = run_out("ffprobe", &[
|
let out = run_out(
|
||||||
"-v", "error", "-show_entries",
|
"ffprobe",
|
||||||
"format=format_name:stream=codec_type,codec_name:stream_disposition=attached_pic", "-of", "json", &path,
|
&[
|
||||||
])
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-show_entries",
|
||||||
|
"format=format_name:stream=codec_type,codec_name:stream_disposition=attached_pic",
|
||||||
|
"-of",
|
||||||
|
"json",
|
||||||
|
&path,
|
||||||
|
],
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let v: serde_json::Value = serde_json::from_str(&out).context("reading ffprobe")?;
|
let v: serde_json::Value = serde_json::from_str(&out).context("reading ffprobe")?;
|
||||||
let streams = v["streams"].as_array().cloned().unwrap_or_default();
|
let streams = v["streams"].as_array().cloned().unwrap_or_default();
|
||||||
let video = streams
|
let video = streams.iter().any(|s| s["codec_type"] == "video" && s["disposition"]["attached_pic"] != 1);
|
||||||
.iter()
|
|
||||||
.any(|s| s["codec_type"] == "video" && s["disposition"]["attached_pic"] != 1);
|
|
||||||
let audio = streams.iter().find(|s| s["codec_type"] == "audio");
|
let audio = streams.iter().find(|s| s["codec_type"] == "audio");
|
||||||
let format = v["format"]["format_name"].as_str().unwrap_or("");
|
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);
|
let plain = |codec: &str| format == codec && audio.is_some_and(|a| a["codec_name"] == codec);
|
||||||
if video || !(plain("mp3") || plain("aac")) {
|
if video || !(plain("mp3") || plain("aac")) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let out = run_out("ffprobe", &[
|
let out = run_out(
|
||||||
"-v", "error", "-select_streams", "a:0", "-show_entries", "packet=pts_time,duration_time",
|
"ffprobe",
|
||||||
"-read_intervals", "%+#1", "-of", "csv=p=0", &path,
|
&[
|
||||||
])
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-select_streams",
|
||||||
|
"a:0",
|
||||||
|
"-show_entries",
|
||||||
|
"packet=pts_time,duration_time",
|
||||||
|
"-read_intervals",
|
||||||
|
"%+#1",
|
||||||
|
"-of",
|
||||||
|
"csv=p=0",
|
||||||
|
&path,
|
||||||
|
],
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let mut f = out.trim().split(',').map(|x| x.trim().parse::<f64>());
|
let mut f = out.trim().split(',').map(|x| x.trim().parse::<f64>());
|
||||||
match (f.next(), f.next()) {
|
match (f.next(), f.next()) {
|
||||||
@@ -682,9 +713,16 @@ mod tests {
|
|||||||
for (ext, codec) in [("mp3", "libmp3lame"), ("aac", "aac"), ("m4a", "aac")] {
|
for (ext, codec) in [("mp3", "libmp3lame"), ("aac", "aac"), ("m4a", "aac")] {
|
||||||
let src = dir.join(format!("t.{ext}"));
|
let src = dir.join(format!("t.{ext}"));
|
||||||
ffmpeg(&[
|
ffmpeg(&[
|
||||||
"-v", "error", "-y", "-f", "lavfi", "-i",
|
"-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",
|
"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(),
|
"-c:a",
|
||||||
|
codec,
|
||||||
|
&src.display().to_string(),
|
||||||
])
|
])
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -707,18 +745,36 @@ mod tests {
|
|||||||
let len = |p: &Path| {
|
let len = |p: &Path| {
|
||||||
let p = p.display().to_string();
|
let p = p.display().to_string();
|
||||||
async move {
|
async move {
|
||||||
run_out("ffprobe", &["-v", "error", "-select_streams", "a:0", "-show_entries", "packet=duration_time", "-of", "csv=p=0", &p])
|
run_out(
|
||||||
.await
|
"ffprobe",
|
||||||
.unwrap()
|
&[
|
||||||
.lines()
|
"-v",
|
||||||
.filter_map(|l| l.trim().trim_end_matches(',').parse::<f64>().ok())
|
"error",
|
||||||
.sum::<f64>()
|
"-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;
|
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");
|
assert!((short * 1000.0 - cut as f64).abs() < 100.0, "{ext}: {short} s shorter, {cut} ms cut");
|
||||||
// Decodes without a complaint.
|
// Decodes without a complaint.
|
||||||
assert!(ffmpeg(&["-v", "error", "-i", &list.display().to_string(), "-f", "null", "-"]).await.unwrap().is_empty());
|
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.
|
// 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 (n, body) = kept(&src, p).await.unwrap();
|
||||||
let parts: Vec<_> = futures_util::TryStreamExt::try_collect(body).await.unwrap();
|
let parts: Vec<_> = futures_util::TryStreamExt::try_collect(body).await.unwrap();
|
||||||
@@ -730,7 +786,12 @@ mod tests {
|
|||||||
let saved = dir.join(format!("saved.{ext}"));
|
let saved = dir.join(format!("saved.{ext}"));
|
||||||
std::fs::write(&saved, &bytes).unwrap();
|
std::fs::write(&saved, &bytes).unwrap();
|
||||||
assert!((len(&saved).await - len(&list).await).abs() < 0.05, "{ext}");
|
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());
|
assert!(
|
||||||
|
ffmpeg(&["-v", "error", "-i", &saved.display().to_string(), "-f", "null", "-"])
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
std::fs::remove_dir_all(&dir).unwrap();
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
}
|
}
|
||||||
@@ -750,6 +811,11 @@ async fn real_file() {
|
|||||||
let name = src.file_name().unwrap().to_string_lossy().into_owned();
|
let name = src.file_name().unwrap().to_string_lossy().into_owned();
|
||||||
for (pause, p) in &plans {
|
for (pause, p) in &plans {
|
||||||
std::fs::write(src.with_file_name(format!("{name}.{pause}.m3u8")), playlist(p, &name)).unwrap();
|
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());
|
eprintln!(
|
||||||
|
"{pause} ms: {} cuts, {:.1} min saved, {} pieces",
|
||||||
|
p.cuts.len(),
|
||||||
|
p.saved_ms() as f64 / 60_000.0,
|
||||||
|
p.pieces.len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,12 @@ pub enum DirError {
|
|||||||
impl std::fmt::Display for DirError {
|
impl std::fmt::Display for DirError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
DirError::NotConfigured => write!(f, "No directory is set up on this server. An admin sets one in Admin, Directory."),
|
DirError::NotConfigured => {
|
||||||
DirError::Refused(401, _) => write!(f, "The directory does not accept this server's token. It may have been revoked."),
|
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(403, msg) => write!(f, "The directory refused: {msg}."),
|
||||||
DirError::Refused(404, _) => write!(f, "That is not in the directory."),
|
DirError::Refused(404, _) => write!(f, "That is not in the directory."),
|
||||||
DirError::Refused(code, msg) => write!(f, "The directory answered {code}: {msg}"),
|
DirError::Refused(code, msg) => write!(f, "The directory answered {code}: {msg}"),
|
||||||
@@ -55,25 +59,53 @@ pub fn configured(cfg: &config::Config) -> bool {
|
|||||||
target(cfg).is_some()
|
target(cfg).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn call<T: DeserializeOwned>(ctx: &Ctx, method: reqwest::Method, path: &str, body: Option<&[SnapshotEntry]>) -> Result<T, DirError> {
|
/// A span the whole call, its answer read and parsed included: the directory's own span ends when it
|
||||||
|
/// has answered, so a slow read of a large answer showed under no span (#250).
|
||||||
|
#[tracing::instrument(name = "directory_call", skip_all, fields(http.request.method = %method, url.path = %path.split('?').next().unwrap_or(path), http.response.body.size))]
|
||||||
|
async fn call<T: DeserializeOwned>(
|
||||||
|
ctx: &Ctx,
|
||||||
|
method: reqwest::Method,
|
||||||
|
path: &str,
|
||||||
|
body: Option<&[SnapshotEntry]>,
|
||||||
|
) -> Result<T, DirError> {
|
||||||
let (base, token) = target(&ctx.cfg()).ok_or(DirError::NotConfigured)?;
|
let (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));
|
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).
|
// The directory's spans join this request's trace (#213).
|
||||||
if let Some(tp) = ipx_directory::otel::traceparent() {
|
if let Some(tp) = ipx_directory::otel::traceparent() {
|
||||||
req = req.header("traceparent", tp);
|
req = req.header("traceparent", tp);
|
||||||
}
|
}
|
||||||
|
// A snapshot is about 100 KB of JSON, sent gzipped; the directory unpacks it (#253).
|
||||||
if let Some(b) = body {
|
if let Some(b) = body {
|
||||||
req = req.json(b);
|
use std::io::Write as _;
|
||||||
|
let json = serde_json::to_vec(b).map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||||
|
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||||
|
gz.write_all(&json).map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||||
|
let packed = gz.finish().map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||||
|
req = req
|
||||||
|
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(reqwest::header::CONTENT_ENCODING, "gzip")
|
||||||
|
.body(packed);
|
||||||
}
|
}
|
||||||
let resp = req.send().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
let resp = req.send().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if !status.is_success() {
|
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();
|
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));
|
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 ().
|
// 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:#}")))?;
|
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:#}")))
|
tracing::Span::current().record("http.response.body.size", body.len());
|
||||||
|
serde_json::from_slice(if body.is_empty() { b"null" } else { &body })
|
||||||
|
.map_err(|e| DirError::Unreachable(format!("its answer did not read: {e:#}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seg(s: &str) -> percent_encoding::PercentEncode<'_> {
|
fn seg(s: &str) -> percent_encoding::PercentEncode<'_> {
|
||||||
@@ -85,15 +117,37 @@ pub async fn me(ctx: &Ctx) -> Result<Me, DirError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn feeds(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
|
pub async fn feeds(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
|
||||||
call(ctx, reqwest::Method::GET, "/v1/feeds", None).await
|
let got: Vec<Listing> = call(ctx, reqwest::Method::GET, "/v1/feeds", None).await?;
|
||||||
|
note_art(got.iter());
|
||||||
|
Ok(got)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn popular(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
|
pub async fn popular(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
|
||||||
call(ctx, reqwest::Method::GET, "/v1/popular?limit=10", None).await
|
let got: Vec<Listing> = call(ctx, reqwest::Method::GET, "/v1/popular?limit=10", None).await?;
|
||||||
|
note_art(got.iter());
|
||||||
|
Ok(got)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn page(ctx: &Ctx, id: &str) -> Result<FeedPage, DirError> {
|
pub async fn page(ctx: &Ctx, id: &str) -> Result<FeedPage, DirError> {
|
||||||
call(ctx, reqwest::Method::GET, &format!("/v1/feeds/{}", seg(id)), None).await
|
let got: FeedPage = call(ctx, reqwest::Method::GET, &format!("/v1/feeds/{}", seg(id)), None).await?;
|
||||||
|
note_art(std::iter::once(&got.listing));
|
||||||
|
Ok(got)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artwork the directory has listed, which `/api/art` serves though no feed here names it: the
|
||||||
|
/// Directory shows podcasts nobody here subscribes to, and drew their initials instead (#273).
|
||||||
|
///
|
||||||
|
/// shortcut: in memory, one address a listed feed, forgotten on a restart until the Directory is
|
||||||
|
/// next read, which the page does when it loads; a table if that ever shows as initials.
|
||||||
|
static LISTED_ART: std::sync::LazyLock<std::sync::Mutex<HashSet<String>>> = std::sync::LazyLock::new(Default::default);
|
||||||
|
|
||||||
|
fn note_art<'a>(listings: impl Iterator<Item = &'a Listing>) {
|
||||||
|
let mut art = LISTED_ART.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
art.extend(listings.filter_map(|l| l.image.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn listed_art(url: &str) -> bool {
|
||||||
|
LISTED_ART.lock().unwrap_or_else(|e| e.into_inner()).contains(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Administering the directory (#206): each needs an rwa token, which the directory checks.
|
// Administering the directory (#206): each needs an rwa token, which the directory checks.
|
||||||
@@ -198,7 +252,8 @@ pub async fn run(ctx: std::sync::Arc<Ctx>, mut stop: tokio::sync::watch::Receive
|
|||||||
let key = serde_json::to_vec(&(target(&cfg), &entries)).unwrap_or_default();
|
let key = serde_json::to_vec(&(target(&cfg), &entries)).unwrap_or_default();
|
||||||
let forced = ctx.directory_resend.swap(false, std::sync::atomic::Ordering::Relaxed);
|
let forced = ctx.directory_resend.swap(false, std::sync::atomic::Ordering::Relaxed);
|
||||||
if forced || resend(last.as_ref(), &key) {
|
if forced || resend(last.as_ref(), &key) {
|
||||||
let span = tracing::info_span!(target: "ipx::directory", "directory_share", feeds = entries.len());
|
let span =
|
||||||
|
tracing::info_span!(target: "ipx::directory", "directory_share", feeds = entries.len());
|
||||||
let got = tracing::Instrument::instrument(share(&ctx, &entries), span).await;
|
let got = tracing::Instrument::instrument(share(&ctx, &entries), span).await;
|
||||||
if got.is_ok() {
|
if got.is_ok() {
|
||||||
last = Some((key, std::time::Instant::now()));
|
last = Some((key, std::time::Instant::now()));
|
||||||
@@ -212,7 +267,12 @@ pub async fn run(ctx: std::sync::Arc<Ctx>, mut stop: tokio::sync::watch::Receive
|
|||||||
};
|
};
|
||||||
if let Some(got) = got {
|
if let Some(got) = got {
|
||||||
match &got {
|
match &got {
|
||||||
Ok(r) => tracing::info!(accepted = r.accepted, refused_private = r.refused_private, refused_blocked = r.refused_blocked, "shared with the directory"),
|
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"),
|
Err(e) => tracing::warn!(error = %e, "could not share with the directory"),
|
||||||
}
|
}
|
||||||
let (result, error) = match got {
|
let (result, error) = match got {
|
||||||
@@ -241,6 +301,23 @@ fn resend(last: Option<&(Vec<u8>, std::time::Instant)>, key: &[u8]) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn artwork_the_directory_listed_is_let_through_and_nothing_else() {
|
||||||
|
let listing = |image: Option<&str>| Listing {
|
||||||
|
id: "x".into(),
|
||||||
|
url: "https://x.example/feed".into(),
|
||||||
|
title: None,
|
||||||
|
image: image.map(str::to_owned),
|
||||||
|
category: None,
|
||||||
|
subcategory: None,
|
||||||
|
podcast: true,
|
||||||
|
subscribers: 1,
|
||||||
|
};
|
||||||
|
note_art([listing(Some("https://img.example/cover.jpg")), listing(None)].iter());
|
||||||
|
assert!(listed_art("https://img.example/cover.jpg"));
|
||||||
|
assert!(!listed_art("http://192.168.1.1/admin"));
|
||||||
|
}
|
||||||
|
|
||||||
fn sub(id: &str, url: &str, edit: impl FnOnce(&mut config::Feed)) -> Sub {
|
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();
|
let mut cfg: config::Feed = serde_json::from_value(serde_json::json!({ "url": url })).unwrap();
|
||||||
edit(&mut cfg);
|
edit(&mut cfg);
|
||||||
|
|||||||
@@ -85,16 +85,8 @@ pub fn filename_for(url: &str, content_disposition: Option<&str>) -> String {
|
|||||||
}
|
}
|
||||||
let from_url = url::Url::parse(url)
|
let from_url = url::Url::parse(url)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|u| {
|
.and_then(|u| u.path_segments().and_then(|mut s| s.rfind(|p| !p.is_empty())).map(str::to_owned))
|
||||||
u.path_segments()
|
.map(|seg| percent_encoding::percent_decode_str(&seg).decode_utf8_lossy().into_owned())
|
||||||
.and_then(|s| s.filter(|p| !p.is_empty()).next_back())
|
|
||||||
.map(str::to_owned)
|
|
||||||
})
|
|
||||||
.map(|seg| {
|
|
||||||
percent_encoding::percent_decode_str(&seg)
|
|
||||||
.decode_utf8_lossy()
|
|
||||||
.into_owned()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
sanitize(&from_url)
|
sanitize(&from_url)
|
||||||
}
|
}
|
||||||
@@ -105,11 +97,7 @@ fn disposition_filename(header: &str) -> Option<String> {
|
|||||||
// RFC 5987 form wins when both are present.
|
// RFC 5987 form wins when both are present.
|
||||||
if let Some(v) = part.strip_prefix("filename*=") {
|
if let Some(v) = part.strip_prefix("filename*=") {
|
||||||
let raw = v.rsplit('\'').next().unwrap_or(v);
|
let raw = v.rsplit('\'').next().unwrap_or(v);
|
||||||
return Some(
|
return Some(percent_encoding::percent_decode_str(raw).decode_utf8_lossy().into_owned());
|
||||||
percent_encoding::percent_decode_str(raw)
|
|
||||||
.decode_utf8_lossy()
|
|
||||||
.into_owned(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for part in header.split(';') {
|
for part in header.split(';') {
|
||||||
@@ -155,21 +143,15 @@ pub async fn download(
|
|||||||
|
|
||||||
let name = filename_for(
|
let name = filename_for(
|
||||||
resp.url().as_str(),
|
resp.url().as_str(),
|
||||||
resp.headers()
|
resp.headers().get(reqwest::header::CONTENT_DISPOSITION).and_then(|v| v.to_str().ok()),
|
||||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
|
||||||
.and_then(|v| v.to_str().ok()),
|
|
||||||
);
|
);
|
||||||
let total = resp.content_length();
|
let total = resp.content_length();
|
||||||
|
|
||||||
let tmp_dir = cfg.general.download_dir.join(".ipx-incomplete");
|
let tmp_dir = cfg.general.download_dir.join(".ipx-incomplete");
|
||||||
tokio::fs::create_dir_all(&tmp_dir)
|
tokio::fs::create_dir_all(&tmp_dir).await.with_context(|| format!("creating {}", tmp_dir.display()))?;
|
||||||
.await
|
|
||||||
.with_context(|| format!("creating {}", tmp_dir.display()))?;
|
|
||||||
let tmp = tmp_dir.join(&name);
|
let tmp = tmp_dir.join(&name);
|
||||||
|
|
||||||
let mut file = tokio::fs::File::create(&tmp)
|
let mut file = tokio::fs::File::create(&tmp).await.with_context(|| format!("creating {}", tmp.display()))?;
|
||||||
.await
|
|
||||||
.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);
|
let _active = crate::metrics::busy(&crate::metrics::STATS.downloads_active);
|
||||||
@@ -199,13 +181,9 @@ pub async fn download(
|
|||||||
/// Moves a completed download into its folder. Same filesystem as the incomplete dir, so
|
/// Moves a completed download into its folder. Same filesystem as the incomplete dir, so
|
||||||
/// this is a rename rather than the original's copy-then-unlink.
|
/// this is a rename rather than the original's copy-then-unlink.
|
||||||
pub async fn place(d: &Downloaded, dir: &Path) -> Result<PathBuf> {
|
pub async fn place(d: &Downloaded, dir: &Path) -> Result<PathBuf> {
|
||||||
tokio::fs::create_dir_all(dir)
|
tokio::fs::create_dir_all(dir).await.with_context(|| format!("creating {}", dir.display()))?;
|
||||||
.await
|
|
||||||
.with_context(|| format!("creating {}", dir.display()))?;
|
|
||||||
let dest = unique_path(dir, &d.name);
|
let dest = unique_path(dir, &d.name);
|
||||||
tokio::fs::rename(&d.tmp, &dest)
|
tokio::fs::rename(&d.tmp, &dest).await.with_context(|| format!("moving into {}", dest.display()))?;
|
||||||
.await
|
|
||||||
.with_context(|| format!("moving into {}", dest.display()))?;
|
|
||||||
Ok(dest)
|
Ok(dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,15 +259,8 @@ fn unique_path(dir: &Path, name: &str) -> PathBuf {
|
|||||||
if !candidate.exists() {
|
if !candidate.exists() {
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
let stem = Path::new(name)
|
let stem = Path::new(name).file_stem().and_then(|s| s.to_str()).unwrap_or(name);
|
||||||
.file_stem()
|
let ext = Path::new(name).extension().and_then(|e| e.to_str()).map(|e| format!(".{e}")).unwrap_or_default();
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or(name);
|
|
||||||
let ext = Path::new(name)
|
|
||||||
.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.map(|e| format!(".{e}"))
|
|
||||||
.unwrap_or_default();
|
|
||||||
for n in 2..1000 {
|
for n in 2..1000 {
|
||||||
let candidate = dir.join(format!("{stem}-{n}{ext}"));
|
let candidate = dir.join(format!("{stem}-{n}{ext}"));
|
||||||
if !candidate.exists() {
|
if !candidate.exists() {
|
||||||
@@ -308,12 +279,7 @@ pub fn folder_for(cfg: &Config, id: &str, feed_cfg: &FeedCfg, title: Option<&str
|
|||||||
match cfg.general.organize {
|
match cfg.general.organize {
|
||||||
Organize::Date => chrono::Local::now().format("%m-%d-%Y").to_string(),
|
Organize::Date => chrono::Local::now().format("%m-%d-%Y").to_string(),
|
||||||
Organize::Feed => {
|
Organize::Feed => {
|
||||||
let raw = feed_cfg
|
let raw = feed_cfg.folder.as_deref().or(title).filter(|s| !s.trim().is_empty()).unwrap_or(id);
|
||||||
.folder
|
|
||||||
.as_deref()
|
|
||||||
.or(title)
|
|
||||||
.filter(|s| !s.trim().is_empty())
|
|
||||||
.unwrap_or(id);
|
|
||||||
raw.split('/')
|
raw.split('/')
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|seg| !seg.is_empty() && *seg != "." && *seg != "..")
|
.filter(|seg| !seg.is_empty() && *seg != "." && *seg != "..")
|
||||||
@@ -410,19 +376,13 @@ mod tests {
|
|||||||
"ep1.mp3",
|
"ep1.mp3",
|
||||||
"query string is not part of the name"
|
"query string is not part of the name"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(filename_for("https://x.com/media/My%20Show%20Ep%201.mp3", None), "My Show Ep 1.mp3");
|
||||||
filename_for("https://x.com/media/My%20Show%20Ep%201.mp3", None),
|
|
||||||
"My Show Ep 1.mp3"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
filename_for("https://x.com/dl?id=9", Some("attachment; filename=\"real name.mp3\"")),
|
filename_for("https://x.com/dl?id=9", Some("attachment; filename=\"real name.mp3\"")),
|
||||||
"real name.mp3",
|
"real name.mp3",
|
||||||
"Content-Disposition overrides the URL"
|
"Content-Disposition overrides the URL"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(filename_for("https://x.com/dl", Some("attachment; filename*=UTF-8''caf%C3%A9.mp3")), "café.mp3");
|
||||||
filename_for("https://x.com/dl", Some("attachment; filename*=UTF-8''caf%C3%A9.mp3")),
|
|
||||||
"café.mp3"
|
|
||||||
);
|
|
||||||
assert_eq!(filename_for("https://x.com/", None), "download");
|
assert_eq!(filename_for("https://x.com/", None), "download");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
filename_for("https://x.com/a/../../etc/passwd", None),
|
filename_for("https://x.com/a/../../etc/passwd", None),
|
||||||
@@ -436,9 +396,21 @@ mod tests {
|
|||||||
let mut cfg = Config::default();
|
let mut cfg = Config::default();
|
||||||
cfg.general.download_dir = "/tmp".into();
|
cfg.general.download_dir = "/tmp".into();
|
||||||
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(),
|
||||||
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
|
folder: Some("Subscriptions/Some | Show".into()),
|
||||||
max_new_per_check: None, username: None, password: None, password_env: None, category: None, listed: false, private: false,
|
group: None,
|
||||||
|
media_types: None,
|
||||||
|
schedule: None,
|
||||||
|
keywords: vec![],
|
||||||
|
allow_explicit: false,
|
||||||
|
auto_download: true,
|
||||||
|
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");
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ pub mod feeds {
|
|||||||
/// are re-derived instead. Customising one promotes it to config.
|
/// are re-derived instead. Customising one promotes it to config.
|
||||||
#[sea_orm(default_value = false)]
|
#[sea_orm(default_value = false)]
|
||||||
pub managed: bool,
|
pub managed: bool,
|
||||||
|
/// SHA-256 of the body last read, in hex: the same body again is read as "not modified"
|
||||||
|
/// (#242). Added after the table was, so `create_missing` adds it, and `same_since`.
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub body_hash: Option<String>,
|
||||||
|
/// When the body last changed: the longer ago, the less often the feed is checked (#242).
|
||||||
|
pub same_since: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
@@ -72,6 +78,9 @@ pub mod entries {
|
|||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub first_seen: i64,
|
pub first_seen: i64,
|
||||||
|
/// The item's Markdown, when its feed sends it beside the HTML (#285).
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub markdown: Option<String>,
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
pub duration: Option<i64>,
|
pub duration: Option<i64>,
|
||||||
@@ -151,6 +160,12 @@ pub mod users {
|
|||||||
pub theme: Option<String>,
|
pub theme: Option<String>,
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub theme_mode: Option<String>,
|
pub theme_mode: Option<String>,
|
||||||
|
/// Their own TypeSafe key, which Jev judges their Signals' items on (#254). Never sent
|
||||||
|
/// back to the page.
|
||||||
|
// shortcut: kept as it is, since every call needs it whole; encrypt it with a key of the
|
||||||
|
// server's if the database is ever shared with anything else.
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub typesafe_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
@@ -204,6 +219,17 @@ pub mod subscriptions {
|
|||||||
/// Pinned to the top of this person's feed list, a feed inside a folder included.
|
/// Pinned to the top of this person's feed list, a feed inside a folder included.
|
||||||
#[sea_orm(default_value = false)]
|
#[sea_orm(default_value = false)]
|
||||||
pub pinned: bool,
|
pub pinned: bool,
|
||||||
|
/// A feed from an OPML or Patreon list this person keeps when the list drops it, or when
|
||||||
|
/// they leave the list: it becomes a feed of its own instead of going (#280).
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub kept: bool,
|
||||||
|
/// Left out of All Subscriptions: this feed, or on an OPML or Patreon creator every show
|
||||||
|
/// in it (#288).
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub hide_from_all: bool,
|
||||||
|
/// Left out of every Signal, and never put to Jev for them, as `hide_from_all` (#289).
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub hide_from_signals: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
owned_by_user!();
|
owned_by_user!();
|
||||||
@@ -276,6 +302,71 @@ pub mod hidden {
|
|||||||
owned_by_user!();
|
owned_by_user!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A feed someone makes from their own subscriptions (#254): the items Jev says fit its
|
||||||
|
/// keywords, whichever feed they came from.
|
||||||
|
pub mod signals {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "signals")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i64,
|
||||||
|
pub user_id: i64,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub name: String,
|
||||||
|
/// JSON array of strings.
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub keywords: String,
|
||||||
|
/// What its owner means by it, in a sentence of their own, when the name and the words
|
||||||
|
/// are not enough (#259).
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Its icon, a Font Awesome Free one as `style/name` (`solid/robot`), in place of its
|
||||||
|
/// initials (#261).
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub created: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What Jev said of each item a Signal has been asked about, fitting or not: an item with no row
|
||||||
|
/// here is still to be asked.
|
||||||
|
pub mod signal_verdicts {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "signal_verdicts")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub signal_id: i64,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub guid: String,
|
||||||
|
pub hit: bool,
|
||||||
|
/// A picked-out item to ask about again, since its Signal changed (#276). It stays in the
|
||||||
|
/// Signal's list until Jev has answered the new question.
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub recheck: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::signals::Entity",
|
||||||
|
from = "Column::SignalId",
|
||||||
|
to = "super::signals::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Signal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
pub mod sessions {
|
pub mod sessions {
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,31 @@ use tokio::sync::{broadcast, mpsc};
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "ev", rename_all = "snake_case")]
|
#[serde(tag = "ev", rename_all = "snake_case")]
|
||||||
pub enum Event {
|
pub enum Event {
|
||||||
FeedStart { feed: String },
|
FeedStart {
|
||||||
FeedSkip { feed: String, reason: String },
|
feed: String,
|
||||||
FeedDone { feed: String, new: usize, downloaded: usize, failed: usize, torrents: usize },
|
},
|
||||||
FeedError { feed: String, msg: String },
|
FeedSkip {
|
||||||
|
feed: String,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
FeedDone {
|
||||||
|
feed: String,
|
||||||
|
new: usize,
|
||||||
|
downloaded: usize,
|
||||||
|
failed: usize,
|
||||||
|
torrents: usize,
|
||||||
|
},
|
||||||
|
FeedError {
|
||||||
|
feed: String,
|
||||||
|
msg: String,
|
||||||
|
},
|
||||||
/// The feed answered from a new address after redirects that all said it moved for good,
|
/// The feed answered from a new address after redirects that all said it moved for good,
|
||||||
/// and the catalogue now has that address (#115).
|
/// and the catalogue now has that address (#115).
|
||||||
FeedMoved { feed: String, from: String, to: String },
|
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,12 +47,38 @@ pub enum Event {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
total: Option<u64>,
|
total: Option<u64>,
|
||||||
},
|
},
|
||||||
DownloadDone { feed: String, enclosure: i64, url: String, path: String, bytes: u64 },
|
DownloadDone {
|
||||||
DownloadError { feed: String, enclosure: i64, url: String, msg: String },
|
feed: String,
|
||||||
TorrentDeferred { feed: String, url: String },
|
enclosure: i64,
|
||||||
|
url: String,
|
||||||
|
path: String,
|
||||||
|
bytes: u64,
|
||||||
|
},
|
||||||
|
DownloadError {
|
||||||
|
feed: String,
|
||||||
|
enclosure: i64,
|
||||||
|
url: String,
|
||||||
|
msg: String,
|
||||||
|
},
|
||||||
|
TorrentDeferred {
|
||||||
|
feed: String,
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
/// DeadAir finished a file's copy (#171), so a page showing it as in progress can stop.
|
/// 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 },
|
DeadairDone {
|
||||||
Reaped { path: String, bytes: u64 },
|
feed: String,
|
||||||
|
enclosure: i64,
|
||||||
|
saved_s: i64,
|
||||||
|
},
|
||||||
|
/// Jev found items for someone's Signals (#258), so their page fetches the counts again.
|
||||||
|
SignalHits {
|
||||||
|
user: i64,
|
||||||
|
hits: usize,
|
||||||
|
},
|
||||||
|
Reaped {
|
||||||
|
path: String,
|
||||||
|
bytes: u64,
|
||||||
|
},
|
||||||
/// Terminal: a client that asked for work stops reading here.
|
/// Terminal: a client that asked for work stops reading here.
|
||||||
ScanDone {
|
ScanDone {
|
||||||
feeds: usize,
|
feeds: usize,
|
||||||
@@ -45,9 +89,18 @@ pub enum Event {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
forced: bool,
|
forced: bool,
|
||||||
},
|
},
|
||||||
ReapDone { files: usize, bytes: u64 },
|
ReapDone {
|
||||||
Status { feeds: usize, pending: i64, downloaded: i64 },
|
files: usize,
|
||||||
Error { msg: String },
|
bytes: u64,
|
||||||
|
},
|
||||||
|
Status {
|
||||||
|
feeds: usize,
|
||||||
|
pending: i64,
|
||||||
|
downloaded: i64,
|
||||||
|
},
|
||||||
|
Error {
|
||||||
|
msg: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Event {
|
impl Event {
|
||||||
@@ -78,10 +131,9 @@ impl Event {
|
|||||||
Event::Reaped { path, bytes } => {
|
Event::Reaped { path, bytes } => {
|
||||||
format!("deleted {path} ({:.1} MB)", *bytes as f64 / 1_048_576.0)
|
format!("deleted {path} ({:.1} MB)", *bytes as f64 / 1_048_576.0)
|
||||||
}
|
}
|
||||||
Event::ReapDone { files, bytes } => format!(
|
Event::ReapDone { files, bytes } => {
|
||||||
"deleted {files} old file(s), {:.1} MB",
|
format!("deleted {files} old file(s), {:.1} MB", *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} queued to download, {downloaded} downloaded")
|
format!("{feeds} feeds, {pending} queued to download, {downloaded} downloaded")
|
||||||
}
|
}
|
||||||
@@ -89,7 +141,10 @@ impl Event {
|
|||||||
// 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::DeadairDone { feed, saved_s, .. } => {
|
||||||
|
format!("{feed}: DeadAir found the silences, {saved_s} s shorter")
|
||||||
|
}
|
||||||
|
Event::SignalHits { hits, .. } => format!("{hits} item(s) picked out for Signals"),
|
||||||
Event::ScanDone { feeds, .. } => format!("scan complete, {feeds} feed(s)"),
|
Event::ScanDone { feeds, .. } => format!("scan complete, {feeds} feed(s)"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -162,7 +217,9 @@ impl Emitter {
|
|||||||
// 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());
|
||||||
}
|
}
|
||||||
if self.print && let Some(line) = e.human() {
|
if self.print
|
||||||
|
&& let Some(line) = e.human()
|
||||||
|
{
|
||||||
println!("{line}");
|
println!("{line}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +236,10 @@ impl Event {
|
|||||||
*url = redact(url);
|
*url = redact(url);
|
||||||
*msg = redact_text(msg);
|
*msg = redact_text(msg);
|
||||||
}
|
}
|
||||||
Event::TorrentDeferred { url, .. } => *url = redact(url),
|
// Progress and DownloadDone too: a Patreon file's address carries its signature (#292).
|
||||||
|
Event::TorrentDeferred { url, .. } | Event::Progress { url, .. } | Event::DownloadDone { url, .. } => {
|
||||||
|
*url = redact(url)
|
||||||
|
}
|
||||||
Event::FeedMoved { from, to, .. } => {
|
Event::FeedMoved { from, to, .. } => {
|
||||||
*from = redact(from);
|
*from = redact(from);
|
||||||
*to = redact(to);
|
*to = redact(to);
|
||||||
@@ -259,9 +319,8 @@ pub async fn daemon_is_live(path: &Path) -> bool {
|
|||||||
/// the meantime. The answer goes to the client that asked and no one else: broadcast, it ended
|
/// 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.
|
/// 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 = std::sync::Arc<
|
pub type StatusFn =
|
||||||
dyn Fn(Command) -> std::pin::Pin<Box<dyn std::future::Future<Output = Event> + Send>> + Send + Sync,
|
std::sync::Arc<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(
|
||||||
@@ -273,14 +332,12 @@ pub async fn serve(
|
|||||||
// A socket file left by a crashed daemon would block the bind; a live one was already
|
// A socket file left by a crashed daemon would block the bind; a live one was already
|
||||||
// rejected by the caller's daemon_is_live() check.
|
// rejected by the caller's daemon_is_live() check.
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
std::fs::remove_file(&path)
|
std::fs::remove_file(&path).with_context(|| format!("removing stale socket {}", path.display()))?;
|
||||||
.with_context(|| format!("removing stale socket {}", path.display()))?;
|
|
||||||
}
|
}
|
||||||
if let Some(dir) = path.parent() {
|
if let Some(dir) = path.parent() {
|
||||||
std::fs::create_dir_all(dir)?;
|
std::fs::create_dir_all(dir)?;
|
||||||
}
|
}
|
||||||
let listener = UnixListener::bind(&path)
|
let listener = UnixListener::bind(&path).with_context(|| format!("binding {}", path.display()))?;
|
||||||
.with_context(|| format!("binding {}", path.display()))?;
|
|
||||||
tracing::info!(socket = %path.display(), "listening");
|
tracing::info!(socket = %path.display(), "listening");
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -367,7 +424,9 @@ pub async fn reload(path: &Path) -> Result<()> {
|
|||||||
}
|
}
|
||||||
anyhow::bail!("the daemon closed the connection without answering")
|
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")?
|
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.
|
||||||
@@ -425,9 +484,28 @@ mod tests {
|
|||||||
url: format!("https://cdn/ep.mp3?{key}"),
|
url: format!("https://cdn/ep.mp3?{key}"),
|
||||||
msg: format!("connecting: error sending request for url (https://cdn/ep.mp3?{key})"),
|
msg: format!("connecting: error sending request for url (https://cdn/ep.mp3?{key})"),
|
||||||
});
|
});
|
||||||
out.emit(Event::FeedMoved { feed: "f".into(), from: format!("https://x/rss?{key}"), to: format!("https://y/rss?{key}") });
|
out.emit(Event::FeedMoved {
|
||||||
// What every open page is sent, whoever's feed it is (#238).
|
feed: "f".into(),
|
||||||
for _ in 0..3 {
|
from: format!("https://x/rss?{key}"),
|
||||||
|
to: format!("https://y/rss?{key}"),
|
||||||
|
});
|
||||||
|
out.emit(Event::Progress {
|
||||||
|
feed: "f".into(),
|
||||||
|
enclosure: 1,
|
||||||
|
url: format!("https://cdn/ep.mp3?{key}"),
|
||||||
|
file: "ep.mp3".into(),
|
||||||
|
done: 1,
|
||||||
|
total: None,
|
||||||
|
});
|
||||||
|
out.emit(Event::DownloadDone {
|
||||||
|
feed: "f".into(),
|
||||||
|
enclosure: 1,
|
||||||
|
url: format!("https://cdn/ep.mp3?{key}"),
|
||||||
|
path: "/downloads/ep.mp3".into(),
|
||||||
|
bytes: 1,
|
||||||
|
});
|
||||||
|
// What every open page is sent, whoever's feed it is (#238, #292).
|
||||||
|
for _ in 0..5 {
|
||||||
let sent = serde_json::to_string(&rx.try_recv().unwrap()).unwrap();
|
let sent = serde_json::to_string(&rx.try_recv().unwrap()).unwrap();
|
||||||
assert!(!sent.contains("SECRET"), "{sent}");
|
assert!(!sent.contains("SECRET"), "{sent}");
|
||||||
assert!(sent.contains("https://"), "the address is still there, without its key: {sent}");
|
assert!(sent.contains("https://"), "the address is still there, without its key: {sent}");
|
||||||
@@ -438,7 +516,10 @@ mod tests {
|
|||||||
fn a_feed_that_moved_says_so_on_the_wire_and_in_words() {
|
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 e = Event::FeedMoved { feed: "x".into(), from: "http://a/f".into(), to: "https://a/f".into() };
|
||||||
let wire = serde_json::to_value(&e).unwrap();
|
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!(
|
||||||
|
(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");
|
assert_eq!(e.human().unwrap(), "x: moved for good from http://a/f to https://a/f; following it");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,14 +558,8 @@ mod tests {
|
|||||||
assert!(json.contains(r#""enclosure":42"#), "a UI needs this to target one row");
|
assert!(json.contains(r#""enclosure":42"#), "a UI needs this to target one row");
|
||||||
|
|
||||||
// total is omitted rather than null when the server sent no length.
|
// total is omitted rather than null when the server sent no length.
|
||||||
let ev = Event::Progress {
|
let ev =
|
||||||
feed: "a".into(),
|
Event::Progress { feed: "a".into(), enclosure: 1, url: "u".into(), file: "f".into(), done: 1, total: None };
|
||||||
enclosure: 1,
|
|
||||||
url: "u".into(),
|
|
||||||
file: "f".into(),
|
|
||||||
done: 1,
|
|
||||||
total: None,
|
|
||||||
};
|
|
||||||
assert!(!serde_json::to_string(&ev).unwrap().contains("total"));
|
assert!(!serde_json::to_string(&ev).unwrap().contains("total"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,14 +567,7 @@ mod tests {
|
|||||||
fn only_completion_events_end_a_client_session() {
|
fn only_completion_events_end_a_client_session() {
|
||||||
assert!(Event::ScanDone { feeds: 1, only: None, forced: false }.is_terminal());
|
assert!(Event::ScanDone { feeds: 1, only: None, forced: false }.is_terminal());
|
||||||
assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal());
|
assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal());
|
||||||
assert!(!Event::FeedDone {
|
assert!(!Event::FeedDone { feed: "a".into(), new: 0, downloaded: 0, failed: 0, torrents: 0 }.is_terminal());
|
||||||
feed: "a".into(),
|
|
||||||
new: 0,
|
|
||||||
downloaded: 0,
|
|
||||||
failed: 0,
|
|
||||||
torrents: 0
|
|
||||||
}
|
|
||||||
.is_terminal());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -27,9 +27,8 @@ struct Ring {
|
|||||||
next_seq: u64,
|
next_seq: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
static BUF: LazyLock<Mutex<Ring>> = LazyLock::new(|| {
|
static BUF: LazyLock<Mutex<Ring>> =
|
||||||
Mutex::new(Ring { lines: VecDeque::with_capacity(CAPACITY), next_seq: 1 })
|
LazyLock::new(|| Mutex::new(Ring { lines: VecDeque::with_capacity(CAPACITY), next_seq: 1 }));
|
||||||
});
|
|
||||||
|
|
||||||
pub fn push(level: &str, target: &str, msg: String) {
|
pub fn push(level: &str, target: &str, msg: String) {
|
||||||
let mut ring = match BUF.lock() {
|
let mut ring = match BUF.lock() {
|
||||||
@@ -57,12 +56,7 @@ pub fn since(after: u64, limit: usize) -> (Vec<LogLine>, u64) {
|
|||||||
Err(p) => p.into_inner(),
|
Err(p) => p.into_inner(),
|
||||||
};
|
};
|
||||||
let latest = ring.next_seq.saturating_sub(1);
|
let latest = ring.next_seq.saturating_sub(1);
|
||||||
let mut out: Vec<LogLine> = ring
|
let mut out: Vec<LogLine> = ring.lines.iter().filter(|l| l.seq > after).cloned().collect();
|
||||||
.lines
|
|
||||||
.iter()
|
|
||||||
.filter(|l| l.seq > after)
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
// On a first load (after = 0) the tail is what matters, not the head.
|
// On a first load (after = 0) the tail is what matters, not the head.
|
||||||
if out.len() > limit {
|
if out.len() > limit {
|
||||||
out.drain(..out.len() - limit);
|
out.drain(..out.len() - limit);
|
||||||
@@ -130,10 +124,7 @@ mod tests {
|
|||||||
let (all, latest) = since(0, CAPACITY * 2);
|
let (all, latest) = since(0, CAPACITY * 2);
|
||||||
assert_eq!(all.len(), CAPACITY, "bounded");
|
assert_eq!(all.len(), CAPACITY, "bounded");
|
||||||
assert!(latest >= (CAPACITY + 50) as u64);
|
assert!(latest >= (CAPACITY + 50) as u64);
|
||||||
assert!(
|
assert!(all.first().unwrap().seq < all.last().unwrap().seq, "oldest first");
|
||||||
all.first().unwrap().seq < all.last().unwrap().seq,
|
|
||||||
"oldest first"
|
|
||||||
);
|
|
||||||
|
|
||||||
// "everything after the last one I saw" must return nothing new.
|
// "everything after the last one I saw" must return nothing new.
|
||||||
let (none, _) = since(latest, 100);
|
let (none, _) = since(latest, 100);
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ pub struct Stats {
|
|||||||
pub feeds: AtomicI64,
|
pub feeds: AtomicI64,
|
||||||
pub pending: AtomicI64,
|
pub pending: AtomicI64,
|
||||||
pub downloaded: AtomicI64,
|
pub downloaded: AtomicI64,
|
||||||
|
/// When the Signals judge last finished a pass, Unix seconds; 0 for not since starting.
|
||||||
|
pub signals_last_pass: AtomicI64,
|
||||||
|
/// Calls to Jev by how they ended, in `JEV_RESULTS`' order, and their microseconds all told.
|
||||||
|
pub jev_calls: [AtomicU64; 5],
|
||||||
|
pub jev_micros: AtomicU64,
|
||||||
|
/// Microseconds calls waited for their turn under TypeSafe's limits (#277), part of `jev_micros`.
|
||||||
|
pub jev_paced_micros: AtomicU64,
|
||||||
|
/// What each person's Signals, icons and key checks used, and whose key paid (#278): items
|
||||||
|
/// judged, Signals they fit, and TypeSafe's input and output tokens. Floats: a call shared by
|
||||||
|
/// several people on the server's key is split between them.
|
||||||
|
people: std::sync::Mutex<std::collections::BTreeMap<(String, &'static str), [f64; 4]>>,
|
||||||
/// The worker's job now, and since when.
|
/// The worker's job now, and since when.
|
||||||
job: std::sync::Mutex<Option<(&'static str, std::time::Instant)>>,
|
job: std::sync::Mutex<Option<(&'static str, std::time::Instant)>>,
|
||||||
/// Jobs finished, by kind: how many, and their seconds all told.
|
/// Jobs finished, by kind: how many, and their seconds all told.
|
||||||
@@ -64,11 +75,26 @@ impl Stats {
|
|||||||
feeds: AtomicI64::new(-1),
|
feeds: AtomicI64::new(-1),
|
||||||
pending: AtomicI64::new(-1),
|
pending: AtomicI64::new(-1),
|
||||||
downloaded: AtomicI64::new(-1),
|
downloaded: AtomicI64::new(-1),
|
||||||
|
signals_last_pass: AtomicI64::new(0),
|
||||||
|
jev_calls: [const { AtomicU64::new(0) }; 5],
|
||||||
|
jev_micros: AtomicU64::new(0),
|
||||||
|
jev_paced_micros: AtomicU64::new(0),
|
||||||
|
people: std::sync::Mutex::new(std::collections::BTreeMap::new()),
|
||||||
job: std::sync::Mutex::new(None),
|
job: std::sync::Mutex::new(None),
|
||||||
jobs: std::sync::Mutex::new(std::collections::BTreeMap::new()),
|
jobs: std::sync::Mutex::new(std::collections::BTreeMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds to what `who` used on `key` ("server" or "own"): items judged, fits, input and output
|
||||||
|
/// tokens.
|
||||||
|
pub fn bill(&self, who: &str, key: &'static str, used: [f64; 4]) {
|
||||||
|
let mut people = self.people.lock().unwrap();
|
||||||
|
let row = people.entry((who.to_owned(), key)).or_default();
|
||||||
|
for (r, u) in row.iter_mut().zip(used) {
|
||||||
|
*r += u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Marks the worker as on a job of `kind` until what it returns is dropped, however the job
|
/// Marks the worker as on a job of `kind` until what it returns is dropped, however the job
|
||||||
/// ends: finished, failed or stopped.
|
/// ends: finished, failed or stopped.
|
||||||
pub fn job(&'static self, kind: &'static str) -> Job {
|
pub fn job(&'static self, kind: &'static str) -> Job {
|
||||||
@@ -90,6 +116,11 @@ impl Drop for Job {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How a call to Jev ended: answered, its key refused (401-403), the request refused as
|
||||||
|
/// malformed (400, 422), over TypeSafe's rate limits (429), or anything else, TypeSafe's 5xx and
|
||||||
|
/// timeouts.
|
||||||
|
pub const JEV_RESULTS: [&str; 5] = ["ok", "key_refused", "malformed", "rate_limited", "error"];
|
||||||
|
|
||||||
/// Counts one more of something in progress until dropped, however it ends.
|
/// Counts one more of something in progress until dropped, however it ends.
|
||||||
pub struct Busy(&'static AtomicI64);
|
pub struct Busy(&'static AtomicI64);
|
||||||
|
|
||||||
@@ -129,13 +160,16 @@ pub fn spawn(bind: &str, s: Sources) -> anyhow::Result<std::net::SocketAddr> {
|
|||||||
Err(e) => return tracing::error!(error = %e, "metrics could not start"),
|
Err(e) => return tracing::error!(error = %e, "metrics could not start"),
|
||||||
};
|
};
|
||||||
rt.block_on(async move {
|
rt.block_on(async move {
|
||||||
let app = axum::Router::new().route(
|
let app = axum::Router::new()
|
||||||
"/metrics",
|
.route(
|
||||||
axum::routing::get(move || {
|
"/metrics",
|
||||||
let s = s.clone();
|
axum::routing::get(move || {
|
||||||
async move { ([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(&s)) }
|
let s = s.clone();
|
||||||
}),
|
async move { ([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(&s)) }
|
||||||
);
|
}),
|
||||||
|
)
|
||||||
|
// Prometheus asks for gzip, every 15 s (#253).
|
||||||
|
.layer(tower_http::compression::CompressionLayer::new().gzip(true));
|
||||||
match tokio::net::TcpListener::from_std(listener) {
|
match tokio::net::TcpListener::from_std(listener) {
|
||||||
Ok(l) => {
|
Ok(l) => {
|
||||||
if let Err(e) = axum::serve(l, app).await {
|
if let Err(e) = axum::serve(l, app).await {
|
||||||
@@ -153,22 +187,59 @@ pub fn spawn(bind: &str, s: Sources) -> anyhow::Result<std::net::SocketAddr> {
|
|||||||
pub fn render(s: &Sources) -> String {
|
pub fn render(s: &Sources) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
let (open, idle, max) = s.ctx.db.pool();
|
let (open, idle, max) = s.ctx.db.pool();
|
||||||
gauge(&mut out, "ipx_db_connections", "Database connections open, in use or idle.",
|
gauge(
|
||||||
&[("state=\"in_use\"", open as f64 - idle as f64), ("state=\"idle\"", idle as f64)]);
|
&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_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.",
|
gauge(
|
||||||
&[("", (s.cmds.max_capacity() - s.cmds.capacity()) as f64)]);
|
&mut out,
|
||||||
gauge(&mut out, "ipx_event_listeners", "Open pages and socket clients listening for live updates.",
|
"ipx_worker_queue",
|
||||||
&[("", s.events.receiver_count() as f64)]);
|
"Commands waiting for the worker: scans, downloads and the like.",
|
||||||
gauge(&mut out, "ipx_torrent_slots_free", "Torrents that could start now.",
|
&[("", (s.cmds.max_capacity() - s.cmds.capacity()) as f64)],
|
||||||
&[("", s.ctx.torrent_slots.available_permits() as f64)]);
|
);
|
||||||
gauge(&mut out, "ipx_deadair_playing", "Episodes someone is playing through DeadAir's playlist.",
|
gauge(
|
||||||
&[("", s.playing.lock().map_or(0, |p| p.len()) as f64)]);
|
&mut out,
|
||||||
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);
|
"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,
|
||||||
|
);
|
||||||
|
gauge(
|
||||||
|
&mut out,
|
||||||
|
"ipx_signals_keys_refused",
|
||||||
|
"People whose TypeSafe key was refused, whose Signals wait until they save another.",
|
||||||
|
&[("", s.ctx.signals_refused.lock().map_or(0, |r| r.len()) as f64)],
|
||||||
|
);
|
||||||
let rt = s.runtime.metrics();
|
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_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_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)]);
|
gauge(
|
||||||
|
&mut out,
|
||||||
|
"ipx_runtime_queue",
|
||||||
|
"Tasks waiting in the runtime's global queue.",
|
||||||
|
&[("", rt.global_queue_depth() as f64)],
|
||||||
|
);
|
||||||
let (rss, fds) = ipx_directory::metrics::process();
|
let (rss, fds) = ipx_directory::metrics::process();
|
||||||
if let Some(v) = rss {
|
if let Some(v) = rss {
|
||||||
gauge(&mut out, "ipx_process_resident_bytes", "Memory the process holds.", &[("", v)]);
|
gauge(&mut out, "ipx_process_resident_bytes", "Memory the process holds.", &[("", v)]);
|
||||||
@@ -176,9 +247,24 @@ pub fn render(s: &Sources) -> String {
|
|||||||
if let Some(v) = fds {
|
if let Some(v) = fds {
|
||||||
gauge(&mut out, "ipx_process_open_fds", "Files and sockets the process has open.", &[("", v)]);
|
gauge(&mut out, "ipx_process_open_fds", "Files and sockets the process has open.", &[("", v)]);
|
||||||
}
|
}
|
||||||
counter(&mut out, "ipx_feed_fetches_total", "Feeds fetched, answered 304 included.", &[("", ipx_feed::FETCHES.load(Relaxed) as f64)]);
|
counter(
|
||||||
counter(&mut out, "ipx_feed_bytes_total", "Bytes of feed bodies fetched.", &[("", ipx_feed::FETCHED_BYTES.load(Relaxed) as f64)]);
|
&mut out,
|
||||||
gauge(&mut out, "ipx_uptime_seconds", "Seconds since the daemon started.", &[("", s.started.elapsed().as_secs() as f64)]);
|
"ipx_feed_fetches_total",
|
||||||
|
"Feeds fetched, answered 304 included.",
|
||||||
|
&[("", ipx_feed::FETCHES.load(Relaxed) as f64)],
|
||||||
|
);
|
||||||
|
counter(
|
||||||
|
&mut out,
|
||||||
|
"ipx_feed_bytes_total",
|
||||||
|
"Bytes of feed bodies fetched.",
|
||||||
|
&[("", ipx_feed::FETCHED_BYTES.load(Relaxed) as f64)],
|
||||||
|
);
|
||||||
|
gauge(
|
||||||
|
&mut out,
|
||||||
|
"ipx_uptime_seconds",
|
||||||
|
"Seconds since the daemon started.",
|
||||||
|
&[("", s.started.elapsed().as_secs() as f64)],
|
||||||
|
);
|
||||||
let version = format!("version=\"{}\"", env!("CARGO_PKG_VERSION"));
|
let version = format!("version=\"{}\"", env!("CARGO_PKG_VERSION"));
|
||||||
gauge(&mut out, "ipx_build_info", "The version running, as a label.", &[(&version, 1.0)]);
|
gauge(&mut out, "ipx_build_info", "The version running, as a label.", &[(&version, 1.0)]);
|
||||||
out
|
out
|
||||||
@@ -191,34 +277,139 @@ fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_li
|
|||||||
gauge(out, "ipx_worker_busy", "1 while the worker is on a job.", &[("", job.is_some() as u8 as f64)]);
|
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 {
|
if let Some((kind, since)) = job {
|
||||||
let label = format!("kind=\"{kind}\"");
|
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.",
|
gauge(
|
||||||
&[(&label, since.elapsed().as_secs_f64())]);
|
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 jobs = st.jobs.lock().unwrap().clone();
|
||||||
let labels: Vec<String> = jobs.keys().map(|k| format!("kind=\"{k}\"")).collect();
|
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<_>>();
|
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_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));
|
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);
|
let last = st.last_scan_done.load(Relaxed);
|
||||||
if last > 0 {
|
if last > 0 {
|
||||||
gauge(out, "ipx_last_scan_age_seconds", "Seconds since a scan last finished.", &[("", (now - last) as f64)]);
|
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(
|
||||||
gauge(out, "ipx_scan_feeds_fetching", "Feeds a scan is fetching at once.", &[("", st.feeds_fetching.load(Relaxed) as f64)]);
|
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)]);
|
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)]);
|
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)]);
|
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)]);
|
counter(
|
||||||
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)]);
|
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);
|
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.",
|
gauge(
|
||||||
&[("", if since > 0 { (now - since) as f64 } else { 0.0 })]);
|
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_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_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)]);
|
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(
|
||||||
counter(out, "ipx_art_requests_total", "Artwork asked for: served from disk, or fetched from the publisher.",
|
out,
|
||||||
&[("result=\"hit\"", st.art_hits.load(Relaxed) as f64), ("result=\"miss\"", st.art_misses.load(Relaxed) as f64)]);
|
"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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
// By person and whose key paid (#278). An account's name is often an email address: /metrics
|
||||||
|
// is on the LAN only, as Prometheus is.
|
||||||
|
let people = st.people.lock().unwrap().clone();
|
||||||
|
let labels: Vec<String> =
|
||||||
|
people.keys().map(|(who, key)| format!("user=\"{}\",key=\"{key}\"", label(who))).collect();
|
||||||
|
let by = |n: usize| labels.iter().zip(people.values()).map(|(l, v)| (l.as_str(), v[n])).collect::<Vec<_>>();
|
||||||
|
counter(
|
||||||
|
out,
|
||||||
|
"ipx_signals_judged_total",
|
||||||
|
"Items Jev has judged for someone's Signals, by person and whose key paid.",
|
||||||
|
&by(0),
|
||||||
|
);
|
||||||
|
counter(out, "ipx_signals_hits_total", "Signals an item was found to fit, by person and whose key paid.", &by(1));
|
||||||
|
let tokens: Vec<String> =
|
||||||
|
labels.iter().flat_map(|l| [format!("kind=\"input\",{l}"), format!("kind=\"output\",{l}")]).collect();
|
||||||
|
let used: Vec<(&str, f64)> =
|
||||||
|
tokens.iter().zip(people.values().flat_map(|v| [v[2], v[3]])).map(|(l, v)| (l.as_str(), v)).collect();
|
||||||
|
counter(
|
||||||
|
out,
|
||||||
|
"ipx_jev_tokens_total",
|
||||||
|
"Tokens TypeSafe counted for calls to Jev, what they cost, by person and whose key paid.",
|
||||||
|
&used,
|
||||||
|
);
|
||||||
|
let last = st.signals_last_pass.load(Relaxed);
|
||||||
|
if last > 0 {
|
||||||
|
gauge(
|
||||||
|
out,
|
||||||
|
"ipx_signals_last_pass_age_seconds",
|
||||||
|
"Seconds since the Signals judge last finished a pass.",
|
||||||
|
&[("", (now - last) as f64)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let labels: Vec<String> = JEV_RESULTS.iter().map(|r| format!("result=\"{r}\"")).collect();
|
||||||
|
let calls: Vec<(&str, f64)> =
|
||||||
|
labels.iter().zip(&st.jev_calls).map(|(l, c)| (l.as_str(), c.load(Relaxed) as f64)).collect();
|
||||||
|
counter(
|
||||||
|
out,
|
||||||
|
"ipx_jev_requests_total",
|
||||||
|
"Calls to TypeSafe's Jev, Signals' and their icons', by how they ended.",
|
||||||
|
&calls,
|
||||||
|
);
|
||||||
|
counter(
|
||||||
|
out,
|
||||||
|
"ipx_jev_seconds_total",
|
||||||
|
"Seconds spent on calls to Jev, their wait for a turn included.",
|
||||||
|
&[("", st.jev_micros.load(Relaxed) as f64 / 1e6)],
|
||||||
|
);
|
||||||
|
counter(
|
||||||
|
out,
|
||||||
|
"ipx_jev_paced_seconds_total",
|
||||||
|
"Seconds calls to Jev waited for their turn under TypeSafe's rate limits.",
|
||||||
|
&[("", st.jev_paced_micros.load(Relaxed) as f64 / 1e6)],
|
||||||
|
);
|
||||||
for (name, help, v) in [
|
for (name, help, v) in [
|
||||||
("ipx_feeds", "Feeds in the catalogue, as last counted.", &st.feeds),
|
("ipx_feeds", "Feeds in the catalogue, as last counted.", &st.feeds),
|
||||||
("ipx_downloads_pending", "Files a scan will download on its own, as last counted.", &st.pending),
|
("ipx_downloads_pending", "Files a scan will download on its own, as last counted.", &st.pending),
|
||||||
@@ -231,6 +422,11 @@ fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_li
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A label's value as Prometheus's text format reads it.
|
||||||
|
fn label(v: &str) -> String {
|
||||||
|
v.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
|
||||||
|
}
|
||||||
|
|
||||||
/// A counter, which only goes up while the daemon runs, in Prometheus's text format.
|
/// 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)]) {
|
pub fn counter(out: &mut String, name: &str, help: &str, samples: &[(&str, f64)]) {
|
||||||
write(out, "counter", name, help, samples);
|
write(out, "counter", name, help, samples);
|
||||||
@@ -276,15 +472,37 @@ mod tests {
|
|||||||
assert_eq!(T.downloads_active.load(Relaxed), 1);
|
assert_eq!(T.downloads_active.load(Relaxed), 1);
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
work(&mut out, &T, 0, 0, 0);
|
work(&mut out, &T, 0, 0, 0);
|
||||||
assert!(out.contains("ipx_worker_busy 1\n") && out.contains("ipx_worker_job_seconds{kind=\"scan\"}"), "{out}");
|
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.
|
// Dropped, as an error or a stop drops them: nothing left in progress, one scan done.
|
||||||
assert_eq!(T.downloads_active.load(Relaxed), 0);
|
assert_eq!(T.downloads_active.load(Relaxed), 0);
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
work(&mut out, &T, 3, 1, 10);
|
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_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_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"));
|
assert!(out.contains("ipx_event_queue 3\n") && out.contains("ipx_feed_retries 1\n"));
|
||||||
|
assert!(
|
||||||
|
out.contains("ipx_jev_requests_total{result=\"key_refused\"} 0\n")
|
||||||
|
&& !out.contains("ipx_signals_last_pass_age_seconds"),
|
||||||
|
"{out}"
|
||||||
|
);
|
||||||
|
T.bill("sam@example.net", "server", [1.0, 1.0, 600.0, 50.0]);
|
||||||
|
T.bill("sam@example.net", "server", [1.0, 0.0, 300.5, 25.0]);
|
||||||
|
T.bill("a \"b\"", "own", [0.0, 0.0, 10.0, 1.0]);
|
||||||
|
let mut out = String::new();
|
||||||
|
work(&mut out, &T, 0, 0, 0);
|
||||||
|
assert!(out.contains("ipx_signals_judged_total{user=\"sam@example.net\",key=\"server\"} 2\n"), "{out}");
|
||||||
|
assert!(
|
||||||
|
out.contains("ipx_jev_tokens_total{kind=\"input\",user=\"sam@example.net\",key=\"server\"} 900.5\n"),
|
||||||
|
"{out}"
|
||||||
|
);
|
||||||
|
assert!(out.contains("ipx_jev_tokens_total{kind=\"output\",user=\"a \\\"b\\\"\",key=\"own\"} 1\n"), "{out}");
|
||||||
// The library's counts are left out until the daemon has counted it.
|
// The library's counts are left out until the daemon has counted it.
|
||||||
assert!(!out.contains("ipx_feeds "));
|
assert!(!out.contains("ipx_feeds "));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,19 +39,23 @@ pub fn pick(candidates: &[Candidate], total: u64, limit: u64) -> Vec<Candidate>
|
|||||||
|
|
||||||
/// Files older than the cutoff. Flagged ones are already excluded by the query.
|
/// Files older than the cutoff. Flagged ones are already excluded by the query.
|
||||||
pub fn aged(candidates: &[Candidate], cutoff: i64) -> Vec<Candidate> {
|
pub fn aged(candidates: &[Candidate], cutoff: i64) -> Vec<Candidate> {
|
||||||
candidates
|
candidates.iter().filter(|c| c.age_key > 0 && c.age_key < cutoff).cloned().collect()
|
||||||
.iter()
|
|
||||||
.filter(|c| c.age_key > 0 && c.age_key < cutoff)
|
|
||||||
.cloned()
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
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. Not while
|
||||||
|
// the folder itself is gone (the array stopped, shfs dropped): every file would look deleted
|
||||||
|
// and the whole library be forgotten at once (#293).
|
||||||
// Each step is a span of the reap's trace, so a slow one says which part was slow.
|
// 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? {
|
let missing = if cfg.general.download_dir.is_dir() {
|
||||||
|
db.missing_files().instrument(step("missing_files")).await?
|
||||||
|
} else {
|
||||||
|
tracing::warn!(dir = %cfg.general.download_dir.display(), "the downloads folder is not there; left every file as it was");
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
for (id, path) in missing {
|
||||||
if !dry_run {
|
if !dry_run {
|
||||||
db.mark_reaped(id).await?;
|
db.mark_reaped(id).await?;
|
||||||
}
|
}
|
||||||
@@ -79,11 +83,8 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
|||||||
if cfg.general.max_total_gb > 0.0 {
|
if cfg.general.max_total_gb > 0.0 {
|
||||||
let limit = (cfg.general.max_total_gb * 1_073_741_824.0) as u64;
|
let limit = (cfg.general.max_total_gb * 1_073_741_824.0) as u64;
|
||||||
// Re-read: the age sweep may already have freed enough.
|
// Re-read: the age sweep may already have freed enough.
|
||||||
let remaining: Vec<Candidate> = candidates
|
let remaining: Vec<Candidate> =
|
||||||
.iter()
|
candidates.iter().filter(|c| !report.aged_out.iter().any(|a| a.id == c.id)).cloned().collect();
|
||||||
.filter(|c| !report.aged_out.iter().any(|a| a.id == c.id))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
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 {
|
||||||
@@ -99,10 +100,7 @@ async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
|||||||
return Ok(c.bytes.max(0) as u64);
|
return Ok(c.bytes.max(0) as u64);
|
||||||
}
|
}
|
||||||
// A torrent's folder measures as a directory entry, not what is in it: the recorded size then.
|
// 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)
|
let size = std::fs::metadata(&c.path).ok().filter(|m| m.is_file()).map_or(c.bytes.max(0) as u64, |m| m.len());
|
||||||
.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) = crate::download::remove(std::path::Path::new(&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
|
||||||
@@ -119,14 +117,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn cand(id: i64, bytes: i64, age_key: i64, read: bool) -> Candidate {
|
fn cand(id: i64, bytes: i64, age_key: i64, read: bool) -> Candidate {
|
||||||
Candidate {
|
Candidate { id, url: format!("https://x/{id}"), path: format!("/tmp/{id}"), bytes, age_key, read }
|
||||||
id,
|
|
||||||
url: format!("https://x/{id}"),
|
|
||||||
path: format!("/tmp/{id}"),
|
|
||||||
bytes,
|
|
||||||
age_key,
|
|
||||||
read,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -183,7 +174,8 @@ mod tests {
|
|||||||
(2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20),
|
(2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20),
|
||||||
(3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30),
|
(3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30),
|
||||||
(4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);",
|
(4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);",
|
||||||
).await
|
)
|
||||||
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let got: Vec<i64> = db.reap_candidates().await.unwrap().iter().map(|c| c.id).collect();
|
let got: Vec<i64> = db.reap_candidates().await.unwrap().iter().map(|c| c.id).collect();
|
||||||
@@ -212,13 +204,31 @@ mod tests {
|
|||||||
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
||||||
(1, 'f', 'heard', 'u1', '/tmp/heard', 10, 'done', 10),
|
(1, 'f', 'heard', 'u1', '/tmp/heard', 10, 'done', 10),
|
||||||
(2, 'f', 'pinned', 'u2', '/tmp/pinned', 10, 'done', 20);",
|
(2, 'f', 'pinned', 'u2', '/tmp/pinned', 10, 'done', 20);",
|
||||||
).await
|
)
|
||||||
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let got: Vec<(i64, bool)> = db.reap_candidates().await.unwrap().iter().map(|c| (c.id, c.read)).collect();
|
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!(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");
|
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "the old item goes, a leaver's pin or not");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_downloads_folder_that_is_gone_forgets_no_file() {
|
||||||
|
let db = Db::memory().await.unwrap();
|
||||||
|
db.exec_for_test(
|
||||||
|
"INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||||
|
(1, 'f', 'a', 'u1', '/nowhere/ipx-test/a.mp3', 'done');",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut cfg = Config::default();
|
||||||
|
cfg.general.download_dir = "/nowhere/ipx-test".into();
|
||||||
|
assert_eq!(run(&cfg, &db, false).await.unwrap().reconciled, 0, "the folder is gone, not the file");
|
||||||
|
|
||||||
|
cfg.general.download_dir = std::env::temp_dir();
|
||||||
|
assert_eq!(run(&cfg, &db, false).await.unwrap().reconciled, 1, "the folder is there and the file is 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();
|
||||||
@@ -233,7 +243,8 @@ mod tests {
|
|||||||
('f', 'recent', 900);
|
('f', 'recent', 900);
|
||||||
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||||
(1, 'f', 'has-file', 'u1', '/tmp/x', 'done');",
|
(1, 'f', 'has-file', 'u1', '/tmp/x', 'done');",
|
||||||
).await
|
)
|
||||||
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "only the old, fileless, unflagged one");
|
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "only the old, fileless, unflagged one");
|
||||||
|
|||||||
695
crates/ipx/src/signals.rs
Normal file
@@ -0,0 +1,695 @@
|
|||||||
|
//! Signals (#254): feeds someone makes from their own subscriptions, holding the items TypeSafe's
|
||||||
|
//! Jev says fit the keywords they gave. Jev is asked on their own key, once for each item, with a
|
||||||
|
//! question for each of their Signals that has not had it yet, and what it says is kept in
|
||||||
|
//! `signal_verdicts`, fitting or not, so nothing is asked twice.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
|
||||||
|
use futures_util::StreamExt as _;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use tracing::Instrument;
|
||||||
|
|
||||||
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
|
|
||||||
|
use crate::Ctx;
|
||||||
|
use crate::db::{Ask, Unjudged, step};
|
||||||
|
use crate::feed;
|
||||||
|
use crate::metrics::STATS;
|
||||||
|
|
||||||
|
/// Items put to Jev a pass. A pass ends early once there are none left, so this only says how
|
||||||
|
/// long the queue is held before people who got a key later are looked at.
|
||||||
|
const BATCH: i64 = 50;
|
||||||
|
/// Between passes when nothing wakes it: a scan or a new Signal does, so this is a backstop.
|
||||||
|
const IDLE: std::time::Duration = std::time::Duration::from_secs(300);
|
||||||
|
/// Calls to Jev in flight at once (#275). One at a time, about 150 ms each, a new Signal's month
|
||||||
|
/// of items took twenty minutes. Enough for `MAX_RATE` at that pace; the pace is `pace`'s to keep.
|
||||||
|
const IN_FLIGHT: usize = 10;
|
||||||
|
/// TypeSafe's default limits are 80 requests and 100,000 input tokens a second (#277). Calls are
|
||||||
|
/// paced under both, with room left for the icon picks and key checks that share them.
|
||||||
|
const MAX_RATE: f64 = 60.0;
|
||||||
|
const MAX_TOKENS: f64 = 80_000.0;
|
||||||
|
/// When the next call may start, for every call ipx makes to Jev.
|
||||||
|
static NEXT_CALL: tokio::sync::Mutex<Option<tokio::time::Instant>> = tokio::sync::Mutex::const_new(None);
|
||||||
|
/// How sure Jev has to be that an item is about the keywords.
|
||||||
|
const THRESHOLD: f64 = 0.5;
|
||||||
|
/// Of an item's text, which is a podcast's whole show notes at times. 2000 until #275: on 55 real
|
||||||
|
/// items and six Signals, 1000 changed none of 330 verdicts and saved an eighth of the input
|
||||||
|
/// tokens; 500 changed one, an Apple podcast's episode no longer about Apple.
|
||||||
|
const TEXT_CHARS: usize = 1000;
|
||||||
|
|
||||||
|
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||||
|
loop {
|
||||||
|
let done = tokio::select! {
|
||||||
|
_ = stop.changed() => return,
|
||||||
|
done = pass(&ctx) => done,
|
||||||
|
};
|
||||||
|
crate::metrics::STATS.signals_last_pass.store(crate::db::now(), Relaxed);
|
||||||
|
match done {
|
||||||
|
Ok(true) => continue,
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(e) => tracing::warn!(error = %format!("{e:#}"), "Signals could not be judged"),
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
_ = stop.changed() => return,
|
||||||
|
_ = ctx.signals.notified() => {}
|
||||||
|
_ = tokio::time::sleep(IDLE) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key every Signal is judged on, when an admin has chosen that the server pays for Jev
|
||||||
|
/// (#271) and the server has a key; None leaves each person to their own.
|
||||||
|
pub fn server_key(ctx: &Ctx) -> Option<String> {
|
||||||
|
if !ctx.cfg().general.signals_on_server {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
typesafe_key()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server's own TypeSafe key, `TYPESAFE_KEY`; set but empty is none, as the browser suite
|
||||||
|
/// sets it so the test daemon never spends a real one.
|
||||||
|
pub fn typesafe_key() -> Option<String> {
|
||||||
|
std::env::var("TYPESAFE_KEY").ok().filter(|k| !k.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One batch. True when there may be more waiting.
|
||||||
|
async fn pass(ctx: &Ctx) -> Result<bool> {
|
||||||
|
let server = server_key(ctx);
|
||||||
|
// People's refused keys are not what is asked with when the server's is.
|
||||||
|
let skip: Vec<i64> =
|
||||||
|
if server.is_some() { Vec::new() } else { ctx.signals_refused.lock().unwrap().iter().copied().collect() };
|
||||||
|
let days = ctx.cfg().general.signal_days;
|
||||||
|
let items = ctx.db.unjudged(&skip, BATCH, server.is_some(), days).await?;
|
||||||
|
if items.is_empty() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let span = tracing::info_span!(
|
||||||
|
"signals",
|
||||||
|
entries = items.len(),
|
||||||
|
calls = tracing::field::Empty,
|
||||||
|
judged = tracing::field::Empty,
|
||||||
|
hits = tracing::field::Empty
|
||||||
|
);
|
||||||
|
async {
|
||||||
|
let groups = calls(&items, server.is_some());
|
||||||
|
let paid = if server.is_some() { "server" } else { "own" };
|
||||||
|
tracing::Span::current().record("calls", groups.len());
|
||||||
|
let refused = std::sync::Mutex::new(HashSet::new());
|
||||||
|
let stopped = AtomicBool::new(false);
|
||||||
|
let mut hits_for = std::collections::BTreeMap::<i64, usize>::new();
|
||||||
|
let mut judged = 0;
|
||||||
|
// Each call starts only when a place is free, so one that sees a key refused or the pass
|
||||||
|
// stopped is not sent; those already in flight are let finish, and their answers kept.
|
||||||
|
// By index: a closure taking a reference here is not Send for every lifetime, which
|
||||||
|
// tokio::spawn asks of the judge's future.
|
||||||
|
let mut answers = futures_util::stream::iter(0..groups.len())
|
||||||
|
.map(|n| {
|
||||||
|
let (group, refused, stopped, server) = (&groups[n], &refused, &stopped, &server);
|
||||||
|
async move {
|
||||||
|
let first = group[0];
|
||||||
|
if stopped.load(Relaxed) || refused.lock().unwrap().contains(&first.user_id) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let key = server.as_deref().or(first.key.as_deref())?;
|
||||||
|
Some((group, judge(ctx, group, key, paid).await))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.buffer_unordered(IN_FLIGHT);
|
||||||
|
while let Some(answer) = answers.next().await {
|
||||||
|
let Some((group, answer)) = answer else { continue };
|
||||||
|
let first = group[0];
|
||||||
|
match answer {
|
||||||
|
Ok(verdicts) => {
|
||||||
|
for (item, verdicts) in group.iter().zip(verdicts) {
|
||||||
|
judged += 1;
|
||||||
|
let hits = verdicts.iter().filter(|(_, h)| *h).count();
|
||||||
|
STATS.bill(&item.user_name, paid, [1.0, hits as f64, 0.0, 0.0]);
|
||||||
|
if hits > 0 {
|
||||||
|
*hits_for.entry(item.user_id).or_default() += hits;
|
||||||
|
tracing::info!(ev = "signal", user = item.user_id, feed = %item.feed_id, new = hits, "an item fits a Signal");
|
||||||
|
}
|
||||||
|
ctx.db.record_verdicts(&item.feed_id, &item.guid, &verdicts).instrument(step("record_verdicts")).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The server's key refused is everyone's: the queue waits for the next pass.
|
||||||
|
Err(e) if refuses_key(&e) && server.is_some() => {
|
||||||
|
if !stopped.swap(true, Relaxed) {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "TypeSafe refused the server's key for Signals");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A key TypeSafe will not take is not sent the rest of the queue. It is tried
|
||||||
|
// again once its owner saves one (`web::patch_me`) or the daemon restarts.
|
||||||
|
Err(e) if refuses_key(&e) => {
|
||||||
|
if refused.lock().unwrap().insert(first.user_id) {
|
||||||
|
tracing::warn!(user = first.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TypeSafe says the request itself is wrong, so every other item's would be too:
|
||||||
|
// the rest wait for the next pass rather than each failing the same way (#257).
|
||||||
|
Err(e) if malformed(&e) => {
|
||||||
|
if !stopped.swap(true, Relaxed) {
|
||||||
|
tracing::warn!(user = first.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signals request as malformed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Over TypeSafe's limits: what is in flight finishes, nothing more starts, and
|
||||||
|
// the next pass waits for `back_off`.
|
||||||
|
Err(e) if rate_limited(&e) => {
|
||||||
|
if !stopped.swap(true, Relaxed) {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "TypeSafe asked Signals to slow down");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Left unjudged, so it is asked again next pass.
|
||||||
|
Err(e) => tracing::warn!(user = first.user_id, feed = %first.feed_id, error = %format!("{e:#}"), "Jev could not judge an item"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(answers);
|
||||||
|
let span = tracing::Span::current();
|
||||||
|
span.record("judged", judged);
|
||||||
|
span.record("hits", hits_for.values().sum::<usize>());
|
||||||
|
// Once a pass, not once an item: a page fetches its counts again on each.
|
||||||
|
for (user, hits) in hits_for {
|
||||||
|
ctx.out.emit(crate::ipc::Event::SignalHits { user, hits });
|
||||||
|
}
|
||||||
|
let refused = refused.into_inner().unwrap();
|
||||||
|
let some_refused = !refused.is_empty();
|
||||||
|
ctx.signals_refused.lock().unwrap().extend(refused);
|
||||||
|
// A pass that only failed would otherwise go straight round again on the same items, and
|
||||||
|
// one TypeSafe refused as malformed would ask again at once, however full.
|
||||||
|
anyhow::Ok(!stopped.into_inner() && (full(&items) || some_refused))
|
||||||
|
}
|
||||||
|
.instrument(span)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The calls a batch makes, each one item and the people asking about it. On the server's key
|
||||||
|
/// everyone's Signals on an item go in one call, its text sent once (#275); on people's own keys
|
||||||
|
/// each pays for their own. `unjudged` puts one item's rows together.
|
||||||
|
fn calls(items: &[Unjudged], share: bool) -> Vec<Vec<&Unjudged>> {
|
||||||
|
let mut out: Vec<Vec<&Unjudged>> = Vec::new();
|
||||||
|
for item in items {
|
||||||
|
match out.last_mut() {
|
||||||
|
Some(g)
|
||||||
|
if share
|
||||||
|
&& (g[0].feed_id.as_str(), g[0].guid.as_str()) == (item.feed_id.as_str(), item.guid.as_str()) =>
|
||||||
|
{
|
||||||
|
g.push(item)
|
||||||
|
}
|
||||||
|
_ => out.push(vec![item]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A call TypeSafe answered with an error, and what it said: its body says why, where the status
|
||||||
|
/// alone said only 422 (#257).
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Refused {
|
||||||
|
status: u16,
|
||||||
|
body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Refused {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
write!(f, "TypeSafe answered {}: {}", self.status, self.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Refused {}
|
||||||
|
|
||||||
|
fn status(e: &anyhow::Error) -> Option<u16> {
|
||||||
|
e.downcast_ref::<Refused>().map(|r| r.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the batch was as long as `unjudged` gives, so more may be waiting. Its limit is on
|
||||||
|
/// rows, one for each item and Signal: with two Signals, 50 is 25 items, and comparing the items
|
||||||
|
/// with 50 took every batch for the last, 25 items every 5 minutes (#260).
|
||||||
|
fn full(items: &[Unjudged]) -> bool {
|
||||||
|
items.iter().map(|i| i.signals.len()).sum::<usize>() as i64 >= BATCH
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refuses_key(e: &anyhow::Error) -> bool {
|
||||||
|
matches!(status(e), Some(401..=403))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rate_limited(e: &anyhow::Error) -> bool {
|
||||||
|
status(e) == Some(429)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A call's turn: it starts at `next` or now, whichever is later, and holds the line for its
|
||||||
|
/// share of a second under both limits. Gives when it starts and when the one after may.
|
||||||
|
fn slot(
|
||||||
|
next: tokio::time::Instant,
|
||||||
|
now: tokio::time::Instant,
|
||||||
|
tokens: f64,
|
||||||
|
) -> (tokio::time::Instant, tokio::time::Instant) {
|
||||||
|
let start = next.max(now);
|
||||||
|
(start, start + std::time::Duration::from_secs_f64((1.0 / MAX_RATE).max(tokens / MAX_TOKENS)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits for this call's turn. Its tokens are guessed from the request, a token to three bytes
|
||||||
|
/// of JSON, on the high side: TypeSafe says the true count only in the answer.
|
||||||
|
async fn pace(body: &serde_json::Value) {
|
||||||
|
let tokens = body.to_string().len() as f64 / 3.0;
|
||||||
|
let start = {
|
||||||
|
let mut next = NEXT_CALL.lock().await;
|
||||||
|
let now = tokio::time::Instant::now();
|
||||||
|
let (start, after) = slot(next.unwrap_or(now), now, tokens);
|
||||||
|
*next = Some(after);
|
||||||
|
start
|
||||||
|
};
|
||||||
|
let waited = start.saturating_duration_since(tokio::time::Instant::now());
|
||||||
|
tokio::time::sleep_until(start).await;
|
||||||
|
// How long the limits held this call back: near nothing unless ipx is near them (#277).
|
||||||
|
STATS.jev_paced_micros.fetch_add(waited.as_micros() as u64, Relaxed);
|
||||||
|
tracing::Span::current().record("paced_ms", waited.as_millis() as u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TypeSafe said too many: nobody calls again until it says, or a second.
|
||||||
|
async fn back_off(retry_after: Option<u64>) {
|
||||||
|
let until = tokio::time::Instant::now() + std::time::Duration::from_secs(retry_after.unwrap_or(1).min(60));
|
||||||
|
let mut next = NEXT_CALL.lock().await;
|
||||||
|
*next = Some(next.map_or(until, |n| n.max(until)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn malformed(e: &anyhow::Error) -> bool {
|
||||||
|
matches!(status(e), Some(400 | 422))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One call about one item, for everyone in `group`: their verdicts, in the group's order.
|
||||||
|
async fn judge(ctx: &Ctx, group: &[&Unjudged], key: &str, paid: &'static str) -> Result<Vec<Vec<(i64, bool)>>> {
|
||||||
|
let asks: Vec<&Ask> = group.iter().flat_map(|i| &i.signals).collect();
|
||||||
|
let span = tracing::info_span!(
|
||||||
|
"jev",
|
||||||
|
user = group[0].user_id,
|
||||||
|
people = group.len(),
|
||||||
|
signal_count = asks.len(),
|
||||||
|
"http.response.status_code" = tracing::field::Empty,
|
||||||
|
error = tracing::field::Empty,
|
||||||
|
"otel.status_code" = tracing::field::Empty,
|
||||||
|
input_tokens = tracing::field::Empty,
|
||||||
|
output_tokens = tracing::field::Empty,
|
||||||
|
paced_ms = tracing::field::Empty,
|
||||||
|
);
|
||||||
|
let answer = called(span, ask(ctx, key, &request(group[0], &asks))).await?;
|
||||||
|
// A shared call is billed by each person's share of its questions, which are most of it.
|
||||||
|
let [input, output] = usage(&answer);
|
||||||
|
for item in group {
|
||||||
|
let share = item.signals.len() as f64 / asks.len() as f64;
|
||||||
|
STATS.bill(&item.user_name, paid, [0.0, 0.0, input * share, output * share]);
|
||||||
|
}
|
||||||
|
// A question Jev left unanswered stays unjudged and is asked again.
|
||||||
|
Ok(group.iter().map(|i| i.signals.iter().filter_map(|a| hit(&answer, a.id).map(|h| (a.id, h))).collect()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The item as Jev sees it, as text, which is what its docs show: an object with an empty feed
|
||||||
|
/// title in it sent a null. One yes-or-no question for each Signal, keyed by its id.
|
||||||
|
fn request(item: &Unjudged, asks: &[&Ask]) -> serde_json::Value {
|
||||||
|
let text: String = text_of(item.description.as_deref().unwrap_or("")).chars().take(TEXT_CHARS).collect();
|
||||||
|
let state = format!(
|
||||||
|
"Feed: {}\nTitle: {}\n\n{text}",
|
||||||
|
item.feed_title.as_deref().unwrap_or(""),
|
||||||
|
item.title.as_deref().unwrap_or("")
|
||||||
|
);
|
||||||
|
let questions: serde_json::Map<String, serde_json::Value> = asks.iter().map(|a| (key(a.id), question(a))).collect();
|
||||||
|
serde_json::json!({ "model": "jev-latest", "state": state, "questions": questions })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(id: i64) -> String {
|
||||||
|
format!("signal_{id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One subject, named by the Signal and described by its words and its owner's sentence. Asked
|
||||||
|
/// whether an item was about any one of its words, AI Development (ai, development, programming)
|
||||||
|
/// took a game's remaster at 0.90 and a C++ tutorial at 0.99; asked about the subject, 0.06 and
|
||||||
|
/// 0.02, with AI code review still 0.94 (#259). `criteria` is an object of what true and false
|
||||||
|
/// mean: a sentence on its own was refused with a 422 (#257).
|
||||||
|
fn question(a: &Ask) -> serde_json::Value {
|
||||||
|
let mut said = format!(
|
||||||
|
"The reader follows a subject they call \"{}\", described by these words: {}.",
|
||||||
|
a.name,
|
||||||
|
a.keywords.join(", ")
|
||||||
|
);
|
||||||
|
if let Some(d) = &a.description {
|
||||||
|
said.push_str(&format!(" In their own words: {d}"));
|
||||||
|
}
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "noul",
|
||||||
|
"instructions": format!("{said} Is this item about that subject?"),
|
||||||
|
"criteria": {
|
||||||
|
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
|
||||||
|
"false": "It is about something else, or touches the subject only in passing.",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether Jev says the item fits Signal `id`; None when the answer does not say.
|
||||||
|
fn hit(answer: &serde_json::Value, id: i64) -> Option<bool> {
|
||||||
|
answer["answers"][key(id)]["noul"].as_f64().map(|p| p >= THRESHOLD)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An icon for a Signal, picked by Jev from the ones the page found for its words (#263): Jev
|
||||||
|
/// chooses among options and cannot name one, so the page searches Font Awesome, whose index is
|
||||||
|
/// ipx-web's, and sends the likeliest. On the server's key, not the Signal owner's: it is the
|
||||||
|
/// server's page doing it, once a Signal.
|
||||||
|
pub async fn pick_icon(
|
||||||
|
ctx: &Ctx,
|
||||||
|
who: &str,
|
||||||
|
key: &str,
|
||||||
|
name: &str,
|
||||||
|
words: &[String],
|
||||||
|
description: Option<&str>,
|
||||||
|
candidates: &[(String, String)],
|
||||||
|
) -> Result<String> {
|
||||||
|
let span = tracing::info_span!(
|
||||||
|
"jev_icon",
|
||||||
|
candidates = candidates.len(),
|
||||||
|
"http.response.status_code" = tracing::field::Empty,
|
||||||
|
error = tracing::field::Empty,
|
||||||
|
"otel.status_code" = tracing::field::Empty,
|
||||||
|
input_tokens = tracing::field::Empty,
|
||||||
|
output_tokens = tracing::field::Empty,
|
||||||
|
paced_ms = tracing::field::Empty,
|
||||||
|
);
|
||||||
|
called(span, async {
|
||||||
|
let answer = ask(ctx, key, &icon_request(name, words, description, candidates)).await?;
|
||||||
|
let [input, output] = usage(&answer);
|
||||||
|
STATS.bill(who, "server", [0.0, 0.0, input, output]);
|
||||||
|
chosen(&answer, candidates).ok_or_else(|| anyhow::anyhow!("Jev chose no icon offered: {answer}"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether TypeSafe takes `key`, by one short question, before it is saved (#271): a key it
|
||||||
|
/// refuses would sit saved with nothing ever picked out, and nobody told why.
|
||||||
|
pub async fn check_key(ctx: &Ctx, who: &str, key: &str) -> Result<()> {
|
||||||
|
let span = tracing::info_span!(
|
||||||
|
"jev_key_check",
|
||||||
|
"http.response.status_code" = tracing::field::Empty,
|
||||||
|
error = tracing::field::Empty,
|
||||||
|
"otel.status_code" = tracing::field::Empty,
|
||||||
|
input_tokens = tracing::field::Empty,
|
||||||
|
output_tokens = tracing::field::Empty,
|
||||||
|
paced_ms = tracing::field::Empty,
|
||||||
|
);
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"model": "jev-latest",
|
||||||
|
"state": "A reader is saving their key.",
|
||||||
|
"questions": { "ok": { "type": "noul", "instructions": "Is this a key check?",
|
||||||
|
"criteria": { "true": "It is.", "false": "It is not." } } },
|
||||||
|
});
|
||||||
|
let answer = called(span, ask(ctx, key, &body)).await?;
|
||||||
|
let [input, output] = usage(&answer);
|
||||||
|
STATS.bill(who, "own", [0.0, 0.0, input, output]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts `body` to Jev on `key` and gives its answer, or what TypeSafe refused it with. Carries
|
||||||
|
/// this trace's `traceparent`, as every call out of ipx does.
|
||||||
|
async fn ask(ctx: &Ctx, key: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
|
||||||
|
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
|
||||||
|
let mut req = ctx.client.post(url).bearer_auth(key).json(body).timeout(feed::FEED_TIMEOUT);
|
||||||
|
if let Some(tp) = ipx_directory::otel::traceparent() {
|
||||||
|
req = req.header("traceparent", tp);
|
||||||
|
}
|
||||||
|
pace(body).await;
|
||||||
|
let r = req.send().await?;
|
||||||
|
let status = r.status().as_u16();
|
||||||
|
tracing::Span::current().record("http.response.status_code", status);
|
||||||
|
if status == 429 {
|
||||||
|
back_off(r.headers().get("retry-after").and_then(|v| v.to_str().ok()?.parse().ok())).await;
|
||||||
|
}
|
||||||
|
if !r.status().is_success() {
|
||||||
|
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
|
||||||
|
return Err(Refused { status, body }.into());
|
||||||
|
}
|
||||||
|
let answer: serde_json::Value = r.json().await?;
|
||||||
|
// What a call cost, which TypeSafe says and nothing else would (#275); billed by the caller,
|
||||||
|
// who knows whose it was (#278).
|
||||||
|
for kind in ["input_tokens", "output_tokens"] {
|
||||||
|
if let Some(t) = answer["usage"][kind].as_u64() {
|
||||||
|
tracing::Span::current().record(kind, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input and output tokens, as an answer counts them; 0 when it does not.
|
||||||
|
fn usage(answer: &serde_json::Value) -> [f64; 2] {
|
||||||
|
["input_tokens", "output_tokens"].map(|k| answer["usage"][k].as_f64().unwrap_or(0.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a call to Jev in `span`, counting it in /metrics by how it ended, and marking the span
|
||||||
|
/// failed, which a field named `error` alone does not: Tempo's failed traces left Jev's out.
|
||||||
|
async fn called<T>(span: tracing::Span, call: impl std::future::Future<Output = Result<T>>) -> Result<T> {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let r = call.instrument(span.clone()).await;
|
||||||
|
STATS.jev_micros.fetch_add(started.elapsed().as_micros() as u64, Relaxed);
|
||||||
|
STATS.jev_calls[outcome(&r)].fetch_add(1, Relaxed);
|
||||||
|
if let Err(e) = &r {
|
||||||
|
span.record("error", format!("{e:#}").as_str());
|
||||||
|
span.record("otel.status_code", "ERROR");
|
||||||
|
}
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of `metrics::JEV_RESULTS` a call's result is.
|
||||||
|
fn outcome<T>(r: &Result<T>) -> usize {
|
||||||
|
match r {
|
||||||
|
Ok(_) => 0,
|
||||||
|
Err(e) if refuses_key(e) => 1,
|
||||||
|
Err(e) if malformed(e) => 2,
|
||||||
|
Err(e) if rate_limited(e) => 3,
|
||||||
|
Err(_) => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn icon_request(
|
||||||
|
name: &str,
|
||||||
|
words: &[String],
|
||||||
|
description: Option<&str>,
|
||||||
|
candidates: &[(String, String)],
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let mut state = format!("Signal: {name}\nListens for: {}", words.join(", "));
|
||||||
|
if let Some(d) = description {
|
||||||
|
state.push_str(&format!("\nIn their words: {d}"));
|
||||||
|
}
|
||||||
|
let criteria: serde_json::Map<String, serde_json::Value> =
|
||||||
|
candidates.iter().map(|(id, label)| (id.clone(), label.clone().into())).collect();
|
||||||
|
serde_json::json!({
|
||||||
|
"model": "jev-latest",
|
||||||
|
"state": state,
|
||||||
|
"questions": { "icon": {
|
||||||
|
"type": "choice",
|
||||||
|
"instructions": "Which icon best stands for this subject in a list of the reader's topics?",
|
||||||
|
"criteria": criteria,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jev's choice, only if it is one it was offered.
|
||||||
|
fn chosen(answer: &serde_json::Value, candidates: &[(String, String)]) -> Option<String> {
|
||||||
|
let c = answer["answers"]["icon"]["choice"].as_str()?;
|
||||||
|
candidates.iter().find(|(id, _)| id == c).map(|(id, _)| id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show notes are HTML: Jev is given their words, and an image's alt and title text, which is all
|
||||||
|
/// the words some posts have. xkcd's is a comic and its hover text; dropping the tag whole sent Jev
|
||||||
|
/// "Ground Effect" alone, an F1 aerodynamics term, and the comic went into Formula 1 (#267).
|
||||||
|
fn text_of(html: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(html.len());
|
||||||
|
let mut tag: Option<String> = None;
|
||||||
|
for c in html.chars() {
|
||||||
|
match (&mut tag, c) {
|
||||||
|
(None, '<') => tag = Some(String::new()),
|
||||||
|
(Some(t), '>') => {
|
||||||
|
let mut said: Vec<&str> = Vec::new();
|
||||||
|
for name in ["alt", "title"] {
|
||||||
|
if let Some(v) = attr(t, name).filter(|v| !v.trim().is_empty() && !said.contains(v)) {
|
||||||
|
said.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for v in said {
|
||||||
|
out.push(' ');
|
||||||
|
out.push_str(v);
|
||||||
|
}
|
||||||
|
out.push(' ');
|
||||||
|
tag = None;
|
||||||
|
}
|
||||||
|
(Some(t), c) => t.push(c),
|
||||||
|
(None, c) => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A quoted attribute's value in a tag's text, `alt="..."` or `alt='...'`.
|
||||||
|
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
|
||||||
|
let lower = tag.to_ascii_lowercase();
|
||||||
|
for q in ['"', '\''] {
|
||||||
|
let key = format!(" {name}={q}");
|
||||||
|
if let Some(at) = lower.find(&key) {
|
||||||
|
let rest = &tag[at + key.len()..];
|
||||||
|
return rest.find(q).map(|end| &rest[..end]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_noul_of_a_half_or_more_is_a_fit() {
|
||||||
|
// As TypeSafe answered on 2026-10-16.
|
||||||
|
let answer = serde_json::json!({ "model": "jev-1.13.0", "answers": {
|
||||||
|
"signal_3": { "type": "noul", "noul": 0.5 }, "signal_4": { "type": "noul", "noul": 0.49 } } });
|
||||||
|
assert_eq!(hit(&answer, 3), Some(true));
|
||||||
|
assert_eq!(hit(&answer, 4), Some(false));
|
||||||
|
assert_eq!(hit(&answer, 5), None, "a question not answered is asked again");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_icon_is_one_jev_was_offered() {
|
||||||
|
let offered = vec![
|
||||||
|
("solid/robot".to_string(), "Robot: ai, bot".to_string()),
|
||||||
|
("solid/laptop-code".to_string(), "Laptop Code".to_string()),
|
||||||
|
];
|
||||||
|
let r = icon_request("AI Development", &["ai".into()], Some("AI for writing software."), &offered);
|
||||||
|
assert_eq!(r["questions"]["icon"]["type"], "choice");
|
||||||
|
assert_eq!(r["questions"]["icon"]["criteria"]["solid/robot"], "Robot: ai, bot");
|
||||||
|
assert!(r["state"].as_str().unwrap().contains("In their words: AI for writing software."));
|
||||||
|
// As TypeSafe answered on 2026-10-16.
|
||||||
|
let answer = serde_json::json!({ "answers": { "icon": { "type": "choice", "choice": "solid/laptop-code", "confidence": 0.34 } } });
|
||||||
|
assert_eq!(chosen(&answer, &offered).as_deref(), Some("solid/laptop-code"));
|
||||||
|
let stray = serde_json::json!({ "answers": { "icon": { "choice": "solid/skull" } } });
|
||||||
|
assert_eq!(chosen(&stray, &offered), None, "never one it was not offered");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_images_words_are_read_with_the_text() {
|
||||||
|
let xkcd = r#"<img src="https://imgs.xkcd.com/comics/ground_effect.png" title="Runners wear sneakers." alt="Runners wear sneakers." referrerpolicy="no-referrer">"#;
|
||||||
|
assert_eq!(text_of(xkcd), "Runners wear sneakers.", "alt and title once, when they say the same");
|
||||||
|
assert_eq!(text_of("<p>Qualifying <b>report</b></p><img alt='A grid'>"), "Qualifying report A grid");
|
||||||
|
assert_eq!(text_of(r#"<img src="x.png" alt="">"#), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_call_is_counted_by_how_it_ended() {
|
||||||
|
let refused = |status| -> Result<()> { Err(Refused { status, body: String::new() }.into()) };
|
||||||
|
let named = |r: Result<()>| crate::metrics::JEV_RESULTS[outcome(&r)];
|
||||||
|
assert_eq!(named(Ok(())), "ok");
|
||||||
|
assert_eq!(named(refused(402)), "key_refused");
|
||||||
|
assert_eq!(named(refused(422)), "malformed");
|
||||||
|
assert_eq!(named(refused(503)), "error");
|
||||||
|
assert_eq!(named(refused(429)), "rate_limited");
|
||||||
|
assert_eq!(named(Err(anyhow::anyhow!("timed out"))), "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn on_the_servers_key_one_item_is_one_call_for_everyone() {
|
||||||
|
let row = |user_id, guid: &str| Unjudged {
|
||||||
|
user_id,
|
||||||
|
user_name: format!("u{user_id}"),
|
||||||
|
key: None,
|
||||||
|
feed_id: "f".into(),
|
||||||
|
guid: guid.into(),
|
||||||
|
feed_title: None,
|
||||||
|
title: None,
|
||||||
|
description: None,
|
||||||
|
signals: vec![Ask { id: user_id * 10, name: "s".into(), keywords: vec![], description: None }],
|
||||||
|
};
|
||||||
|
let items = [row(1, "x"), row(2, "x"), row(1, "y")];
|
||||||
|
let shape = |share| {
|
||||||
|
calls(&items, share).iter().map(|g| g.iter().map(|i| i.user_id).collect::<Vec<_>>()).collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
assert_eq!(shape(true), [vec![1, 2], vec![1]], "x asked once, for both");
|
||||||
|
assert_eq!(shape(false), [vec![1], vec![2], vec![1]], "each on their own key");
|
||||||
|
let both: Vec<&Ask> = calls(&items, true)[0].iter().flat_map(|i| &i.signals).collect();
|
||||||
|
let r = request(&items[0], &both);
|
||||||
|
assert!(r["questions"]["signal_10"].is_object() && r["questions"]["signal_20"].is_object());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn calls_are_paced_under_both_of_typesafes_limits() {
|
||||||
|
let t0 = tokio::time::Instant::now();
|
||||||
|
let ms = |a: tokio::time::Instant| (a - t0).as_secs_f64() * 1000.0;
|
||||||
|
// A small call holds the line for a 60th of a second, under 80 requests a second.
|
||||||
|
let (start, next) = slot(t0, t0, 1000.0);
|
||||||
|
assert_eq!((ms(start), ms(next).round()), (0.0, 17.0));
|
||||||
|
// One of 8,000 tokens, a tenth of a second, under 100,000 tokens a second.
|
||||||
|
let (start, next) = slot(next, t0, 8000.0);
|
||||||
|
assert_eq!((ms(start).round(), ms(next).round()), (17.0, 117.0));
|
||||||
|
// After a quiet spell, a call starts at once.
|
||||||
|
let later = t0 + std::time::Duration::from_secs(5);
|
||||||
|
assert_eq!(slot(next, later, 1000.0).0, later);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_batch_is_full_by_its_rows_not_its_items() {
|
||||||
|
let item = |n: usize| Unjudged {
|
||||||
|
user_id: 1,
|
||||||
|
user_name: "a".into(),
|
||||||
|
key: Some("k".into()),
|
||||||
|
feed_id: "f".into(),
|
||||||
|
guid: "g".into(),
|
||||||
|
feed_title: None,
|
||||||
|
title: None,
|
||||||
|
description: None,
|
||||||
|
signals: (0..n as i64)
|
||||||
|
.map(|id| Ask { id, name: "s".into(), keywords: vec![], description: None })
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
full(&(0..25).map(|_| item(2)).collect::<Vec<_>>()),
|
||||||
|
"25 items of two Signals each is the 50 asked for"
|
||||||
|
);
|
||||||
|
assert!(!full(&(0..24).map(|_| item(2)).collect::<Vec<_>>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_signal_is_its_own_question_on_the_items_words() {
|
||||||
|
let item = Unjudged {
|
||||||
|
user_id: 1,
|
||||||
|
user_name: "a".into(),
|
||||||
|
key: Some("k".into()),
|
||||||
|
feed_id: "f".into(),
|
||||||
|
guid: "g".into(),
|
||||||
|
feed_title: Some("Pit Lane".into()),
|
||||||
|
title: Some("Monaco".into()),
|
||||||
|
description: Some("<p>Qualifying <b>report</b></p>".into()),
|
||||||
|
signals: vec![
|
||||||
|
Ask { id: 3, name: "F1".into(), keywords: vec!["formula 1".into(), "f1".into()], description: None },
|
||||||
|
Ask {
|
||||||
|
id: 4,
|
||||||
|
name: "Food".into(),
|
||||||
|
keywords: vec!["cooking".into()],
|
||||||
|
description: Some("Recipes, not restaurants.".into()),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let r = request(&item, &item.signals.iter().collect::<Vec<_>>());
|
||||||
|
assert_eq!(r["state"], "Feed: Pit Lane\nTitle: Monaco\n\nQualifying report");
|
||||||
|
let q = &r["questions"]["signal_3"];
|
||||||
|
assert_eq!(q["type"], "noul");
|
||||||
|
let says = q["instructions"].as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
says.contains("\"F1\"") && says.contains("formula 1, f1"),
|
||||||
|
"one subject, by its name and its words: {says}"
|
||||||
|
);
|
||||||
|
assert!(!says.contains("own words"), "no sentence, none said");
|
||||||
|
assert!(
|
||||||
|
q["criteria"]["true"].is_string() && q["criteria"]["false"].is_string(),
|
||||||
|
"TypeSafe takes an object, not a sentence"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
r["questions"]["signal_4"]["instructions"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("In their own words: Recipes, not restaurants.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,10 +26,7 @@ impl Torrents {
|
|||||||
let listen_addr: SocketAddr = format!("0.0.0.0:{lo}").parse()?;
|
let listen_addr: SocketAddr = format!("0.0.0.0:{lo}").parse()?;
|
||||||
|
|
||||||
let opts = SessionOptions {
|
let opts = SessionOptions {
|
||||||
listen: Some(librqbit::ListenerOptions {
|
listen: Some(librqbit::ListenerOptions { listen_addr, ..Default::default() }),
|
||||||
listen_addr,
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
client_name_and_version: Some(concat!("ipx ", env!("CARGO_PKG_VERSION")).into()),
|
client_name_and_version: Some(concat!("ipx ", env!("CARGO_PKG_VERSION")).into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -72,12 +69,7 @@ impl Torrents {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| anyhow::anyhow!("no metadata after {} minutes, gave up", limit.as_secs() / 60))?
|
||||||
anyhow::anyhow!(
|
|
||||||
"no metadata after {} minutes, gave up",
|
|
||||||
limit.as_secs() / 60
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
.context("adding the torrent")?;
|
.context("adding the torrent")?;
|
||||||
|
|
||||||
let (id, handle) = match added {
|
let (id, handle) = match added {
|
||||||
@@ -116,10 +108,7 @@ impl Torrents {
|
|||||||
let size = handle.stats().total_bytes;
|
let size = handle.stats().total_bytes;
|
||||||
|
|
||||||
self.seed(cfg, &handle).await;
|
self.seed(cfg, &handle).await;
|
||||||
self.session
|
self.session.delete(id.into(), false).await.context("releasing the torrent")?;
|
||||||
.delete(id.into(), false)
|
|
||||||
.await
|
|
||||||
.context("releasing the torrent")?;
|
|
||||||
|
|
||||||
Ok((path, size))
|
Ok((path, size))
|
||||||
}
|
}
|
||||||
|
|||||||
34
deny.toml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# cargo deny check: known vulnerabilities, licences and where crates come from. Run before a push
|
||||||
|
# (CLAUDE.md, Tests). It reads Cargo.lock and the RustSec database, and builds nothing.
|
||||||
|
|
||||||
|
[graph]
|
||||||
|
# Production runs on Linux; Windows and macOS-only crates are not ipx's concern.
|
||||||
|
targets = ["x86_64-unknown-linux-gnu"]
|
||||||
|
|
||||||
|
[advisories]
|
||||||
|
ignore = [
|
||||||
|
# ringbuf 0.4's double free needs an element whose Drop panics; librqbit-utp's buffer holds
|
||||||
|
# only u8, which has no Drop. Its fix is ringbuf 0.5, which librqbit 9.0.1 cannot take (#281).
|
||||||
|
{ id = "RUSTSEC-2026-0293", reason = "librqbit-utp's ring buffer holds u8: no Drop to panic" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[licenses]
|
||||||
|
# Permissive licences only; anything else is a question for the owner before it comes in.
|
||||||
|
allow = [
|
||||||
|
"MIT", "MIT-0", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause",
|
||||||
|
"ISC", "Zlib", "Unlicense", "0BSD", "CC0-1.0", "BSL-1.0", "Unicode-3.0", "MPL-2.0",
|
||||||
|
"CDLA-Permissive-2.0",
|
||||||
|
]
|
||||||
|
# ipx's own crates have no licence field and are not published.
|
||||||
|
private = { ignore = true }
|
||||||
|
|
||||||
|
[bans]
|
||||||
|
# Two versions of one crate (syn 1 and 2, and 25 more) only cost build time, and a warning for
|
||||||
|
# each on every run would be read by nobody.
|
||||||
|
multiple-versions = "allow"
|
||||||
|
wildcards = "deny"
|
||||||
|
allow-wildcard-paths = true
|
||||||
|
|
||||||
|
[sources]
|
||||||
|
unknown-registry = "deny"
|
||||||
|
unknown-git = "deny"
|
||||||
@@ -41,6 +41,7 @@ id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web]
|
|||||||
| `crates/ipx/src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
|
| `crates/ipx/src/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/directory.rs` | Asking the directory service, and sharing this server's public feeds with it | — |
|
||||||
| `crates/ipx/src/metrics.rs` | `/metrics` for Prometheus | — |
|
| `crates/ipx/src/metrics.rs` | `/metrics` for Prometheus | — |
|
||||||
|
| `crates/ipx/src/signals.rs` | Signals: asks Jev, on each person's TypeSafe key, which of their items fit their Signals | — |
|
||||||
| `crates/directory/src/lib.rs` | The directory's HTTP API, its token check, the refresher | — |
|
| `crates/directory/src/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/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/config.rs` | The directory's config file: its tokens and their access | — |
|
||||||
@@ -60,7 +61,10 @@ and the files, and no page.
|
|||||||
## A scan
|
## A scan
|
||||||
|
|
||||||
1. Skip the feed unless `last_checked + max(schedule, ttl)` has passed (`--force` ignores this).
|
1. Skip the feed unless `last_checked + max(schedule, ttl)` has passed (`--force` ignores this).
|
||||||
2. Conditional GET with the stored `ETag` / `Last-Modified`. `304` ends it there.
|
Without a schedule of its own, a feed whose body has been the same for a while waits longer:
|
||||||
|
a twelfth of how long it has been the same, up to four hours (#242).
|
||||||
|
2. Conditional GET with the stored `ETag` / `Last-Modified`. `304` ends it there, and so does a
|
||||||
|
body identical to the last one (`body_hash`), from a server that ignores the validators.
|
||||||
3. Sniff the body: RSS, then Atom, then OPML. An OPML is a live subscription — its feeds are
|
3. Sniff the body: RSS, then Atom, then OPML. An OPML is a live subscription — its feeds are
|
||||||
re-derived into the database each scan, never written to config.toml. A Patreon creator link
|
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
|
||||||
@@ -81,7 +85,8 @@ and the files, and no page.
|
|||||||
|
|
||||||
```
|
```
|
||||||
feeds id, url, title, image, category, description, etag, last_modified,
|
feeds id, url, title, image, category, description, etag, last_modified,
|
||||||
last_checked, ttl_mins, last_error, orphaned, group_id, managed
|
last_checked, ttl_mins, last_error, orphaned, group_id, managed,
|
||||||
|
body_hash, same_since
|
||||||
entries feed_id, guid, title, link, published, description, first_seen,
|
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,
|
||||||
@@ -92,8 +97,17 @@ subscriptions user_id, feed_id, keywords, auto_download, allow_explicit,
|
|||||||
max_new_per_check PK (user_id, feed_id)
|
max_new_per_check PK (user_id, feed_id)
|
||||||
entry_state user_id, feed_id, guid, read, flagged, position
|
entry_state user_id, feed_id, guid, read, flagged, position
|
||||||
PK (user_id, feed_id, guid)
|
PK (user_id, feed_id, guid)
|
||||||
|
signals id, user_id, name, keywords
|
||||||
|
signal_verdicts signal_id, feed_id, guid, hit PK (signal_id, feed_id, guid)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A Signal (#254, [signals.md](signals.md)) is a feed someone makes from their own subscriptions. `signals::run`, a task of its
|
||||||
|
own beside DeadAir's, woken by a scan with something new or a Signal made or changed, asks Jev
|
||||||
|
about each item of the last 30 days that has no verdict yet for one of its owner's Signals: one
|
||||||
|
call an item, a noul question a Signal, on the owner's `users.typesafe_key`. Every answer is kept,
|
||||||
|
fitting or not, so nothing is asked twice; new keywords drop the Signal's verdicts. A Signal's
|
||||||
|
items are `entries_where` with an `EXISTS` on its hits, so read state is the item's own.
|
||||||
|
|
||||||
Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position` columns from
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ art_cache_mb = 500 # artwork kept on disk. 0 = none
|
|||||||
database as described above; `download_dir`, `socket` and `organize` stay in config.toml.
|
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. A feed that keeps failing
|
be polled *less* often, and a per-feed `schedule` overrides both. Without a schedule of its own,
|
||||||
|
a feed that sends the same body check after check (Patreon, Substack, which ignore "not
|
||||||
|
modified") is checked less often the longer it stays the same, up to every four hours, and is
|
||||||
|
back on schedule once it changes. A feed that keeps failing
|
||||||
waits as long as it has been failing before the next try, up to a day, and is back on schedule
|
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
|
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.
|
the feed, failed, and it is tried again in ten minutes. Admin-only from the UI.
|
||||||
@@ -162,7 +165,11 @@ subscribes to is checked once a day. One that is dead (failing for 30 days) or q
|
|||||||
new in a year), with nobody subscribed and no file on disk, is removed from the catalogue.
|
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. So
|
||||||
|
does Keep, the plus on a show's page, for whoever turns it on (`kept` on their subscription, #280): when
|
||||||
|
the OPML stops listing the show, or nobody subscribes to the OPML any more, it becomes a catalogue
|
||||||
|
entry outside the OPML instead of going, and stays on the lists of those who kept it. Anyone who
|
||||||
|
had it only through the OPML loses it as before. Leaving the OPML yourself keeps what you kept.
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
`ipx-directory` is a catalogue of public podcasts and blogs that ipx servers share (#204). An ipx
|
`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
|
server sends it the public feeds its people subscribe to, and how many of them do. The directory
|
||||||
fetches each feed's title, artwork, category, description and latest twenty items every six hours,
|
fetches each feed's title, artwork, category, description and latest twenty items every twelve hours,
|
||||||
and any server holding a token can browse it. It never downloads a file, and it never stores
|
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.
|
one's address: every ipx server still fetches its own feeds.
|
||||||
|
|
||||||
@@ -81,6 +81,8 @@ bind = "0.0.0.0:8100"
|
|||||||
# Prometheus metrics, on a port of their own and not the one the tunnel publishes. Empty, or
|
# Prometheus metrics, on a port of their own and not the one the tunnel publishes. Empty, or
|
||||||
# left out, serves none.
|
# left out, serves none.
|
||||||
metrics_bind = "0.0.0.0:9188"
|
metrics_bind = "0.0.0.0:9188"
|
||||||
|
# Apple Podcasts' top charts for this country, weekly (see below). Empty, or left out: none.
|
||||||
|
apple_country = "ca"
|
||||||
# The environment variable holding the database URL: postgres://... or sqlite://...
|
# The environment variable holding the database URL: postgres://... or sqlite://...
|
||||||
database_url_env = "DIRECTORY_DATABASE_URL"
|
database_url_env = "DIRECTORY_DATABASE_URL"
|
||||||
|
|
||||||
@@ -100,6 +102,23 @@ production does with `deployment.environment.name=production`. Its version is in
|
|||||||
`crates/directory/Cargo.toml`, logged at start and answered by `ipx-directory --version`; its
|
`crates/directory/Cargo.toml`, logged at start and answered by `ipx-directory --version`; its
|
||||||
changelog is `crates/directory/CHANGELOG.md`.
|
changelog is `crates/directory/CHANGELOG.md`.
|
||||||
|
|
||||||
|
## Apple Podcasts
|
||||||
|
|
||||||
|
With `apple_country` set, the directory takes Apple Podcasts' top charts for that country once a
|
||||||
|
week (#252): the top 100 overall and the top 50 of each of Apple's categories, about a thousand
|
||||||
|
shows. Apple's lookup API gives each one's RSS address, and they go in as a snapshot of their own,
|
||||||
|
`apple-podcasts`, with no subscribers: fetched, sorted into categories and listed like any other
|
||||||
|
feed, while the most subscribed stay what the servers sharing here subscribe to. A show Apple drops
|
||||||
|
from its charts leaves the directory at the next sync, unless a server shares it. An admin can drop
|
||||||
|
the snapshot as any server's (`DELETE /v1/instances/apple-podcasts`); the next sync brings it back.
|
||||||
|
No token may be named `apple-podcasts`.
|
||||||
|
|
||||||
|
The same sync compares Apple's list of categories with the one ipx keeps (`CATEGORIES` in
|
||||||
|
`crates/feed`) and logs a warning naming what differs, for whoever checks the log to update it.
|
||||||
|
It runs at start when the last sync is a week old, and a failed one tries again an hour later. Each
|
||||||
|
sync is a trace (`apple_sync`, a span for each of Apple's answers), and a line `Apple Podcasts
|
||||||
|
synced` says how many shows came and went in.
|
||||||
|
|
||||||
## Running one
|
## Running one
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
415
docs/signals.md
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
# Signals
|
||||||
|
|
||||||
|
A Signal is a feed someone makes from their own subscriptions (#254). They give it a name, the
|
||||||
|
words to listen for (`formula 1`, `f1`) and, when those are not enough, a sentence saying what they
|
||||||
|
mean (#259). TypeSafe's Jev reads each item of the feeds they subscribe to and picks out the ones
|
||||||
|
about that subject: the name, the words and the sentence together, not any one word. Those items show in the Signal as well as in their
|
||||||
|
own feed. The rest stays noise, where it was.
|
||||||
|
|
||||||
|
Whose TypeSafe key pays for Jev is the admin's choice (#271), under Admin, Server, **Signals for
|
||||||
|
everyone, on this server's TypeSafe key** (`signals_on_server`):
|
||||||
|
|
||||||
|
- **On**, with the server's key in `TYPESAFE_KEY`: everyone has Signals, with nothing to set up.
|
||||||
|
Every item is asked about on the server's key, and keys people saved are kept but not used.
|
||||||
|
- **Off**, the default, or on with no `TYPESAFE_KEY`: each person turns Signals on in Settings
|
||||||
|
with a key of their own. Nothing is judged for someone without one, and their Signals folder is
|
||||||
|
not shown.
|
||||||
|
|
||||||
|
Jev is asked once for each item, on that key, with one question for each of the person's Signals
|
||||||
|
that has not heard about the item yet.
|
||||||
|
|
||||||
|
## What someone sees
|
||||||
|
|
||||||
|
- **The sidebar.** Once Signals are on for someone, a Signals folder comes first in the feed list, under All Subscriptions (#255,
|
||||||
|
#256). It is closed or open as an OPML's folder is, remembered in each browser. Its row is like
|
||||||
|
any folder's (#265); New signal is the + on the folder's page. Inside the folder are the
|
||||||
|
person's Signals, each with its items and unread counted under its name.
|
||||||
|
- **An icon of its own** (#261), chosen in the Signal's dialog from all of Font Awesome Free 7.3.1,
|
||||||
|
the set the page's own icons come from: 2,883 icons, solid, regular and brands, searched by name
|
||||||
|
and Font Awesome's search terms ("ai" finds the robot). Without one, a Signal shows its initials.
|
||||||
|
**Auto** asks Jev to choose (#263), and a Signal made without an icon gets Jev's pick by
|
||||||
|
itself, a moment after it is made. Every Signal's tile is in the accent tint the folder has.
|
||||||
|
- **Each Signal's row** says "12 items · 3 unread", as a feed's row counts its own.
|
||||||
|
- **The folder's page** lists the Signals, with the + for a new one.
|
||||||
|
- **A Signal's page** is an ordinary item list: All, Unread, Downloaded and Pinned, search and sort.
|
||||||
|
Its header says what it listens for (the sentence, or the words), and, on people's own keys,
|
||||||
|
that nothing is picked out without one. Edit changes the name, the words or the sentence; Delete removes the Signal
|
||||||
|
and leaves the items in their feeds.
|
||||||
|
- **Read state is the item's.** Reading an item in a Signal reads it in its feed, and the other way
|
||||||
|
round. The counts follow without a reload (#258).
|
||||||
|
- **Settings, Signals.** On the server's key, a line saying the server pays. On people's own, a
|
||||||
|
Signals switch: turning it on shows a password field for the key, which saves on leaving the
|
||||||
|
field once TypeSafe has taken it (one short question on that key); a key TypeSafe refuses is
|
||||||
|
not saved, and the page says so. Turning the switch off, or the bin, forgets the key and hides
|
||||||
|
the folder; the Signals are kept. The key is never sent back to the page, which only learns
|
||||||
|
whether one is set.
|
||||||
|
|
||||||
|
## How an item gets into a Signal
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant W as Scan worker
|
||||||
|
participant J as Signals judge<br/>(signals::run)
|
||||||
|
participant DB as Database
|
||||||
|
participant T as TypeSafe<br/>api.typesafe.ai
|
||||||
|
participant P as The page
|
||||||
|
|
||||||
|
W->>DB: new entries stored
|
||||||
|
W->>J: ctx.signals.notify_one()
|
||||||
|
Note over J: also woken by a new Signal,<br/>new keywords, a key saved,<br/>or every 5 minutes
|
||||||
|
J->>DB: unjudged(skip refused keys, 50)
|
||||||
|
DB-->>J: items, each with the Signals<br/>that have not heard of it
|
||||||
|
loop each item
|
||||||
|
J->>T: POST /v1/systemone<br/>one noul question per Signal
|
||||||
|
T-->>J: answers: noul 0..1 per question
|
||||||
|
J->>DB: record_verdicts (hit = noul >= 0.5)
|
||||||
|
end
|
||||||
|
J-->>P: signal_hits event (SSE), once a pass per person
|
||||||
|
P->>DB: GET /api/signals (counts),<br/>and the open Signal's list
|
||||||
|
```
|
||||||
|
|
||||||
|
The judge is a task of its own beside DeadAir's (`crates/ipx/src/signals.rs`), not a job on the
|
||||||
|
scan worker, so a slow TypeSafe never holds up a scan.
|
||||||
|
|
||||||
|
### Which items are asked about
|
||||||
|
|
||||||
|
`Db::unjudged` finds, newest first, up to 50 items that are:
|
||||||
|
|
||||||
|
- in a feed the Signal's owner subscribes to,
|
||||||
|
- not hidden from them by their block lists,
|
||||||
|
- for someone with a key that TypeSafe has not refused since the daemon started,
|
||||||
|
- and either published (or, with no date, first seen) in the look-back window and not yet in
|
||||||
|
`signal_verdicts` for that Signal, or picked out by it before it was edited (`recheck`), however
|
||||||
|
old.
|
||||||
|
|
||||||
|
The window is the admin's, Admin, Server, **Signals look back (days)** (`signal_days`, 7 by
|
||||||
|
default, 1 to 365; #276): each item in it is a call, and a month of production's feeds was about
|
||||||
|
8,000. So a new Signal looks back that far, and after that sees each new item. A show subscribed
|
||||||
|
to today has its back catalogue skipped, apart from the window.
|
||||||
|
|
||||||
|
The recent half reads the index `entries_recent`, on each feed's items by date, the expression it
|
||||||
|
is asked by; without it each wake read all of `entries`, 605 MB, 566 ms to find nothing (#274).
|
||||||
|
|
||||||
|
Rows for one item and one person are grouped, so an item is one call whatever the number of
|
||||||
|
Signals. On the server's key, everyone's Signals on the same item go in one call too, its text
|
||||||
|
sent once (#275). Up to ten calls are in flight at once (`IN_FLIGHT`): one at a time, about 150 ms
|
||||||
|
each, a month of items took twenty minutes. Every call to Jev, Signals', icons' and key checks',
|
||||||
|
takes a turn (`pace`, #277): TypeSafe's default limits are 80 requests and 100,000 input tokens a
|
||||||
|
second, and ipx keeps to 60 and 80,000, each call holding the line for a 60th of a second or its
|
||||||
|
guessed tokens' share, whichever is longer. A 429 holds every call back for its `Retry-After`, or
|
||||||
|
a second, and ends the pass. Each `jev` span says how long it waited (`paced_ms`), and
|
||||||
|
`ipx_jev_paced_seconds_total` adds them up; the dashboard draws calls and tokens a second against
|
||||||
|
the limits. A full batch means there may be more, and the next pass
|
||||||
|
starts at once. Otherwise the judge sleeps until woken or for 5 minutes.
|
||||||
|
|
||||||
|
### Every answer is kept
|
||||||
|
|
||||||
|
`signal_verdicts` records each answer, fitting or not. An item with no row has not been asked yet;
|
||||||
|
an item with a row is never asked again. So the cost is one call per item per person, once.
|
||||||
|
Changing a Signal's name, words or sentence marks what it picked out to be asked again
|
||||||
|
(`recheck`), and those stay in its list until answered; what it passed over stays passed over, and
|
||||||
|
new items are asked the new question as they come (#276). The first start with sentences (#259) deleted every verdict, since they answered the
|
||||||
|
old question.
|
||||||
|
|
||||||
|
## TypeSafe
|
||||||
|
|
||||||
|
Jev is TypeSafe's System One model, already used to pick a feed's Directory category
|
||||||
|
(`feed::classify`). The address is `feed::JEV_URL`, `https://api.typesafe.ai/v1/systemone`.
|
||||||
|
Signals use the **noul** question type, which answers how likely a statement is true, from 0 to 1.
|
||||||
|
Questions in one call are judged independently and in parallel.
|
||||||
|
|
||||||
|
### A request
|
||||||
|
|
||||||
|
Sent with `Authorization: Bearer <the key>`, the server's or the person's, for an item and two of their Signals:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "jev-latest",
|
||||||
|
"state": "Feed: Pit Lane\nTitle: Monaco qualifying\n\nQualifying report from the Monaco Grand Prix.",
|
||||||
|
"questions": {
|
||||||
|
"signal_3": {
|
||||||
|
"type": "noul",
|
||||||
|
"instructions": "The reader follows a subject they call \"F1\", described by these words: formula 1, f1. Is this item about that subject?",
|
||||||
|
"criteria": {
|
||||||
|
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
|
||||||
|
"false": "It is about something else, or touches the subject only in passing."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signal_4": {
|
||||||
|
"type": "noul",
|
||||||
|
"instructions": "The reader follows a subject they call \"Food\", described by these words: baking. In their own words: Bread and cakes, not restaurants. Is this item about that subject?",
|
||||||
|
"criteria": {
|
||||||
|
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
|
||||||
|
"false": "It is about something else, or touches the subject only in passing."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `state` is text: the feed's title, the item's title, and its show notes with the HTML taken out,
|
||||||
|
cut to 1,000 characters (`TEXT_CHARS`). It was 2,000 until #275: on 55 real items and six
|
||||||
|
Signals, 1,000 changed none of 330 verdicts and saved 12% of the input tokens (1,266 to 1,112 a
|
||||||
|
call); 500 changed one. Most of the input is the questions themselves, not the item.
|
||||||
|
- Each question is keyed `signal_<id>`, and its answer comes back under the same key.
|
||||||
|
- `criteria` is an object saying what true and false mean. A sentence there is refused with a 422
|
||||||
|
(#257).
|
||||||
|
- **One subject, not any word.** The first question was "Is this item about any of these: ai,
|
||||||
|
development, programming?", so one broad word was enough: AI Development took Sega pulling a game
|
||||||
|
remaster (0.90), a Surface Laptop review (0.95) and a C++ tutorial (0.99), 54 items in all. Asked
|
||||||
|
about the subject, with the sentence "AI tools for writing software: coding agents, AI dev tools,
|
||||||
|
vibe coding, AI in code review.", those scored 0.06, 0.16 and 0.02, and 5 of the 54 stayed in,
|
||||||
|
AI code review in the Linux kernel highest at 0.94 (#259).
|
||||||
|
|
||||||
|
### Its answer
|
||||||
|
|
||||||
|
What TypeSafe answered to a request of this shape, on 2026-10-16:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "jev-1.13.0",
|
||||||
|
"answers": {
|
||||||
|
"signal_3": { "type": "noul", "noul": 0.96 },
|
||||||
|
"signal_4": { "type": "noul", "noul": 0.02 }
|
||||||
|
},
|
||||||
|
"usage": { "input_tokens": 389, "output_tokens": 40 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
0.5 or more is a fit (`THRESHOLD`): the item goes into Signal 3 and not Signal 4. A question
|
||||||
|
missing from the answer is left unjudged and asked again next pass.
|
||||||
|
|
||||||
|
### When TypeSafe says no
|
||||||
|
|
||||||
|
| TypeSafe answers | What the judge does |
|
||||||
|
|---|---|
|
||||||
|
| 401, 402, 403, on the server's key | Everyone's key is refused. The pass ends and the queue waits for the next one, five minutes on. Logged: "TypeSafe refused the server's key for Signals". |
|
||||||
|
| 401, 402, 403, on a person's key | The key is refused. That person is passed over until they save a key or the daemon restarts, so a bad key is not sent the whole queue. Logged once: "TypeSafe refused a Signal key". |
|
||||||
|
| 400, 422 | The request itself is wrong, so every item's would be. The pass ends; the items wait for the next one. Logged: "TypeSafe refused a Signals request as malformed", with TypeSafe's reason. |
|
||||||
|
| 429 | Over TypeSafe's limits. Every call waits for `Retry-After` or a second; the pass ends, what was in flight kept, and the rest wait for the next. Logged once: "TypeSafe asked Signals to slow down". |
|
||||||
|
| anything else, or no answer | That item is left unjudged and asked again next pass. Logged: "Jev could not judge an item". |
|
||||||
|
|
||||||
|
The error keeps TypeSafe's reply, cut to 500 characters, for example:
|
||||||
|
|
||||||
|
```
|
||||||
|
TypeSafe answered 422: {"detail":[{"type":"model_attributes_type","loc":["body","questions","3","noul","criteria"],"msg":"Input should be a valid dictionary or object to extract fields from","input":"Yes if the subject is one of them."}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## The API
|
||||||
|
|
||||||
|
Every route is the signed-in person's own. A Signal of someone else's is not found, as one that
|
||||||
|
does not exist.
|
||||||
|
|
||||||
|
### `GET /api/signals`
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "id": 1, "name": "AI Development", "keywords": ["ai", "development", "programming"],
|
||||||
|
"description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
|
||||||
|
"icon": "solid/laptop-code", "items": 48, "unread": 5 },
|
||||||
|
{ "id": 2, "name": "Apple", "keywords": ["apple", "mac", "iphone"], "description": null, "icon": null, "items": 272, "unread": 0 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`items` counts the items Jev picked out that are still in a subscribed feed and not hidden, what
|
||||||
|
its list shows; `unread` those of them not read.
|
||||||
|
|
||||||
|
### `POST /api/signals`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "name": "Formula 1", "keywords": ["formula 1", "f1"], "description": "Grand prix racing, not road cars." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Answers `{ "id": 3 }` and wakes the judge. A name is 1 to 100 characters. Between 1 and 50 words
|
||||||
|
are allowed, each up to 200 characters, kept once whatever their case. `description` is optional,
|
||||||
|
up to 500 characters. Anything else is a `400` saying so.
|
||||||
|
|
||||||
|
### `PATCH /api/signals/{id}`
|
||||||
|
|
||||||
|
Any of the three: `{ "name": "F1" }`, `{ "keywords": ["f1", "grand prix"] }`,
|
||||||
|
`{ "description": null }` (null or empty clears it), `{ "icon": "solid/robot" }`. A change to the
|
||||||
|
name, words or sentence drops the Signal's verdicts and wakes the judge, since each is part of the
|
||||||
|
question; a new icon does not. The page sends only what changed. An icon is `solid/`, `regular/` or
|
||||||
|
`brands/` and a plain name, since the page puts it into an address: anything else is a `400`. Answers `204`, or `404` for a Signal that is not yours.
|
||||||
|
|
||||||
|
### `DELETE /api/signals/{id}`
|
||||||
|
|
||||||
|
Answers `204`. The Signal and its verdicts go; the items stay in their feeds.
|
||||||
|
|
||||||
|
### `GET /api/signals/{id}/entries`
|
||||||
|
|
||||||
|
The same query as a feed's list, `offset`, `limit`, `filter`, `q`, `sort`, `dir`:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/signals/3/entries?filter=unread&sort=published&dir=desc&limit=50
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": 1,
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"guid": "sig-1", "feed_id": "pit-lane", "title": "Monaco qualifying",
|
||||||
|
"link": "https://pitlane.example/monaco", "published": 1792170000,
|
||||||
|
"description": "<p>Qualifying report…</p>", "read": false, "flagged": false,
|
||||||
|
"image": null, "duration": null, "episode": null, "season": null, "position": 0,
|
||||||
|
"enclosures": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reading or pinning one is the item's own route, `POST /api/entries/{feed_id}/{guid}/flags`, as
|
||||||
|
anywhere else.
|
||||||
|
|
||||||
|
### `PATCH /api/me`, the key
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "typesafe_key": "ts_…" }
|
||||||
|
```
|
||||||
|
|
||||||
|
A string is checked with TypeSafe, by one short question on that key, then saved, and the judge
|
||||||
|
tries it again. `null` or an empty string forgets it. A key is up to 500 printable characters.
|
||||||
|
A key TypeSafe refuses is a `400` ("TypeSafe did not accept that key") and not saved; TypeSafe not
|
||||||
|
answering is a `502`, and the key is not saved either. `GET /api/me` says `"typesafe_key_set":
|
||||||
|
true` and nothing more about the key, and `"signals": "server"` or `"own"`, whose key is used.
|
||||||
|
|
||||||
|
### `PATCH /api/settings`, who pays
|
||||||
|
|
||||||
|
An admin's `{ "signals_on_server": true }` judges everyone's Signals on `TYPESAFE_KEY`, and
|
||||||
|
`false` on each person's own. `GET /api/settings` has `signals_on_server` and
|
||||||
|
`typesafe_server_key`, whether the server has a key to use; without one the setting changes
|
||||||
|
nothing. A change wakes the judge and lets keys refused before be tried again.
|
||||||
|
|
||||||
|
### `POST /api/signals/icon`
|
||||||
|
|
||||||
|
Jev's pick of an icon, on the **server's** `TYPESAFE_KEY`, not the person's: it is the page's own
|
||||||
|
doing, once a Signal. Jev chooses among options and cannot name one, so the page searches Font
|
||||||
|
Awesome's index for each word of the name, words and sentence, the best six each, and sends up to 40:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "AI Development",
|
||||||
|
"keywords": ["ai", "development", "programming"],
|
||||||
|
"description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
|
||||||
|
"candidates": [["solid/robot", "Robot: ai, android, bot, machine"], ["solid/laptop-code", "Laptop Code: computer, cpu, dell, demo"], ["solid/code", "Code: brackets, code, development, html"]]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ipx asks a **choice** question, each candidate a choice and its label and terms what it means:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "jev-latest",
|
||||||
|
"state": "Signal: AI Development\nListens for: ai, development, programming\nIn their words: AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
|
||||||
|
"questions": { "icon": {
|
||||||
|
"type": "choice",
|
||||||
|
"instructions": "Which icon best stands for this subject in a list of the reader's topics?",
|
||||||
|
"criteria": { "solid/robot": "Robot: ai, android, bot, machine", "solid/laptop-code": "Laptop Code: computer, cpu, dell, demo" }
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
TypeSafe answered, on 2026-10-16, for six candidates:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "model": "jev-1.13.0", "answers": { "icon": { "type": "choice", "choice": "solid/laptop-code", "confidence": 0.34,
|
||||||
|
"probabilities": { "solid/robot": 0.4, "solid/code": 0.14, "solid/laptop-code": 0.45, "solid/brain": 0.01, "solid/microchip": 0.0, "solid/terminal": 0.0 } } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
The answer is `{ "icon": "solid/laptop-code" }`, only ever one it was offered. Each candidate has to
|
||||||
|
be an icon name the API takes, 1 to 60 of them. `404` when the server has no key, `502` when Jev
|
||||||
|
fails (logged: "Jev could not pick an icon"). The call has a `jev_icon` span.
|
||||||
|
|
||||||
|
### The event
|
||||||
|
|
||||||
|
Once a pass, for each person it found items for, the judge sends this on the event stream
|
||||||
|
(`/api/events`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ev": "signal_hits", "user": 2, "hits": 3 }
|
||||||
|
```
|
||||||
|
|
||||||
|
A page with Signals fetches `/api/signals` again, and the open Signal's list.
|
||||||
|
|
||||||
|
## Data
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
users ||--o{ signals : owns
|
||||||
|
signals ||--o{ signal_verdicts : "has heard of"
|
||||||
|
entries ||--o{ signal_verdicts : "judged in"
|
||||||
|
users {
|
||||||
|
bigint id
|
||||||
|
text typesafe_key "NULL: no key"
|
||||||
|
}
|
||||||
|
signals {
|
||||||
|
bigint id
|
||||||
|
bigint user_id
|
||||||
|
text name
|
||||||
|
text keywords "JSON array"
|
||||||
|
text description "NULL: none"
|
||||||
|
text icon "solid/robot, NULL: initials"
|
||||||
|
bigint created
|
||||||
|
}
|
||||||
|
signal_verdicts {
|
||||||
|
bigint signal_id "PK"
|
||||||
|
text feed_id "PK"
|
||||||
|
text guid "PK"
|
||||||
|
bool hit
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `signals` and `signal_verdicts` are created from their entities like every table
|
||||||
|
(`crates/ipx/src/entity.rs`). `users.typesafe_key`, `signals.description` and `signals.icon`
|
||||||
|
were added to existing tables, so `create_missing` adds each column when it is missing.
|
||||||
|
- The icons are files of ipx-web's, not the database's: `web/build.mjs` copies Font Awesome Free's
|
||||||
|
SVGs to `/icons/<style>/<name>.svg`, kept a day, and writes `/icons/index.json`, names, labels
|
||||||
|
and search terms, about 90 KB compressed, named by its hash and fetched when the picker first
|
||||||
|
opens. A Signal's tile draws its one file as a CSS mask, so it takes the theme's colour.
|
||||||
|
- A Signal's list is `entries_where` with
|
||||||
|
`EXISTS (SELECT 1 FROM signal_verdicts v WHERE v.signal_id = $n AND v.feed_id = e.feed_id AND v.guid = e.guid AND v.hit)`
|
||||||
|
on top of the subscribed-feeds scope. So filters, search, sort, block lists and read state all
|
||||||
|
work as for All Subscriptions.
|
||||||
|
- A verdict for a Signal deleted while Jev was being asked is not stored. Postgres's foreign key
|
||||||
|
would refuse it, and SQLite would keep it for nothing.
|
||||||
|
|
||||||
|
## Watching it
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Traces | A `signals` span for each pass (`entries`), and a `jev` span for each call (`user`, `signal_count`, `http.response.status_code`, and TypeSafe's reply as `error` when it refuses). |
|
||||||
|
| Log, counted | `ev="signal"`, "an item fits a Signal", for each item with a fit: `user`, `feed`, `new` (how many of their Signals it fits). |
|
||||||
|
| Log, warnings | The three in the table above. |
|
||||||
|
| Events | `signal_hits` on the socket and `/api/events`. |
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
- **The key is kept in plain text** in `users.typesafe_key`, because every call needs it whole. It
|
||||||
|
should be encrypted with a server key if the database is ever shared with anything else.
|
||||||
|
- **Refused keys are remembered in memory**, so a restart tries each once again.
|
||||||
|
- **Keywords changed while an item is being judged** can leave that one item's verdict from the old
|
||||||
|
words.
|
||||||
|
- **The meter is the server's**, `ipx_jev_tokens_total` and `ipx_jev_requests_total` on
|
||||||
|
`/metrics`, and the Signals row of the dashboard: tokens as TypeSafe's answers count them, by
|
||||||
|
person (`user`, the account's name) and whose key paid (`key`, `server` or `own`; #278), with
|
||||||
|
`ipx_signals_judged_total` and `ipx_signals_hits_total` the same way. A call shared by several
|
||||||
|
people on the server's key is split by each one's share of its questions. Icon picks bill the
|
||||||
|
person on the server's key, key checks the person on their own. The dashboard's **Who used what**
|
||||||
|
sums them over its time range. It is
|
||||||
|
one call per item in the person's feeds as they come, and per item in the window for a new Signal.
|
||||||
|
- **Only the window** is judged by publish date, apart from what an edit asks again.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `cargo test -p ipx`: the request's shape and the threshold (`signals::tests`), and the queue,
|
||||||
|
verdicts, ownership, unread counts and new keywords (`db::tests::a_signal_shows_what_jev_said_fits_and_only_to_its_owner`),
|
||||||
|
on SQLite and Postgres both.
|
||||||
|
- `node --test tests/page-unit.js`: a Signal's place id and its list's address.
|
||||||
|
- `npx playwright test -g Signal`: makes a Signal from the folder's +, and checks that the matching
|
||||||
|
item shows and that reading it reads it in its feed and clears the badge. TypeSafe is stood in for
|
||||||
|
by the fixture server's `/jev` (`IPX_TYPESAFE_URL`), which refuses the shape TypeSafe refuses, and
|
||||||
|
`/signal.xml` is dated today, since only the window is judged. They spend the TypeSafe key, so
|
||||||
|
each runs only when the Signals code or the test has changed since it last passed (`signalsTest`;
|
||||||
|
`IPX_SIGNALS_TESTS=1` runs them anyway).
|
||||||
@@ -185,6 +185,15 @@ ts("Page response time", [loki(f'quantile_over_time(0.95, {WEB} | unwrap duratio
|
|||||||
loki(f'max_over_time({WEB} | unwrap duration [$__interval]) by ()', ref="B", legend="slowest")],
|
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.")
|
16, w=8, unit="s", desc="A page waits on ipx's /api/page-check before it is sent.")
|
||||||
y += 8
|
y += 8
|
||||||
|
# What the person waits for, from the page itself (#246): a click to the list drawn.
|
||||||
|
LIST = SEL + ' |= "\\"ev\\":\\"list_shown\\"" | json | __error__=""'
|
||||||
|
ts("A click to the list shown", [loki(f'quantile_over_time(0.95, {LIST} | unwrap ms [$__interval]) by ()', legend="95th percentile"),
|
||||||
|
loki(f'quantile_over_time(0.5, {LIST} | unwrap ms [$__interval]) by ()', ref="B", legend="median"),
|
||||||
|
loki(f'max_over_time({LIST} | unwrap ms [$__interval]) by ()', ref="C", legend="slowest")],
|
||||||
|
0, w=12, unit="ms", desc="Measured in the browser, network and drawing included. The API's part is in the click's trace.")
|
||||||
|
table("Slowest lists by view", [loki(f'topk(10, max_over_time({LIST} | unwrap ms [$__range]) by (feed, filter))', instant=True)],
|
||||||
|
12, rename={"feed": "View", "filter": "Filter", "Value": "Slowest (ms)"}, sort=[{"displayName": "Slowest (ms)", "desc": True}])
|
||||||
|
y += 8
|
||||||
|
|
||||||
# ---- ipx: the API it answers behind ipx-web
|
# ---- ipx: the API it answers behind ipx-web
|
||||||
row("ipx: API")
|
row("ipx: API")
|
||||||
@@ -245,18 +254,77 @@ table("Failed downloads", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev=
|
|||||||
12, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
|
12, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
|
||||||
y += 8
|
y += 8
|
||||||
|
|
||||||
|
# ---- ipx: Signals, items put to TypeSafe's Jev on each person's own key (#254)
|
||||||
|
row("ipx: Signals")
|
||||||
|
ts("Items judged", [prom('sum(increase(ipx_signals_judged_total{job="ipx"}[$__rate_interval]))', legend="judged"),
|
||||||
|
prom('sum(increase(ipx_signals_hits_total{job="ipx"}[$__rate_interval]))', ref="B", legend="fit a Signal")],
|
||||||
|
0, w=8, bars=True, desc="Each item is judged once against every Signal of its subscribers that has not had it; a burst follows a scan or a new Signal.")
|
||||||
|
ts("Calls to Jev", [prom('sum by (result) (increase(ipx_jev_requests_total{job="ipx"}[$__rate_interval]))', legend="{{result}}"),
|
||||||
|
prom('(sum(increase(ipx_jev_seconds_total{job="ipx"}[$__rate_interval])) - sum(increase(ipx_jev_paced_seconds_total{job="ipx"}[$__rate_interval]))) / '
|
||||||
|
'sum(increase(ipx_jev_requests_total{job="ipx"}[$__rate_interval]))', ref="B", legend="seconds a call")],
|
||||||
|
8, w=8, bars=True, stack=True,
|
||||||
|
desc="key_refused stops that person's Signals until they save another key; malformed stops the pass and is ipx's to fix; "
|
||||||
|
"rate_limited is TypeSafe saying slow down (#277), and every call waits; "
|
||||||
|
"error is TypeSafe failing or slow, and the item is asked again next pass. Seconds a call is TypeSafe's, without the wait for a turn.")["fieldConfig"]["overrides"] = [
|
||||||
|
{"matcher": {"id": "byName", "options": "seconds a call"},
|
||||||
|
"properties": [{"id": "unit", "value": "s"}, {"id": "custom.axisPlacement", "value": "right"},
|
||||||
|
{"id": "custom.drawStyle", "value": "line"}, {"id": "custom.stacking", "value": {"mode": "none"}}]}]
|
||||||
|
ts("Signals judge", [prom('max(ipx_signals_last_pass_age_seconds{job="ipx"})', legend="since the last pass"),
|
||||||
|
prom('max(ipx_signals_keys_refused{job="ipx"})', ref="B", legend="keys refused")],
|
||||||
|
16, w=8, unit="s", desc="It passes at least every five minutes; much longer means it is stuck on Jev. Keys refused, on the right, "
|
||||||
|
"are people whose Signals wait for a new key.")["fieldConfig"]["overrides"] = [
|
||||||
|
{"matcher": {"id": "byName", "options": "keys refused"},
|
||||||
|
"properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"}]}]
|
||||||
|
y += 8
|
||||||
|
ts("Tokens", [prom('sum by (kind) (increase(ipx_jev_tokens_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
|
||||||
|
0, w=8, bars=True, stack=True, desc="What TypeSafe counted for each call, as its answers say: what Signals, icons and key checks cost.")
|
||||||
|
ts("Tokens a call", [prom('sum by (kind) (increase(ipx_jev_tokens_total{job="ipx"}[$__rate_interval])) / ignoring(kind) group_left '
|
||||||
|
'sum(increase(ipx_jev_requests_total{job="ipx", result="ok"}[$__rate_interval]))', legend="{{kind}}")],
|
||||||
|
8, w=8, desc="Most of the input is the questions, one for each Signal, then the item's text, cut at 1,000 characters.")
|
||||||
|
# TypeSafe's default limits (#277): ipx paces itself to 60 calls and 80,000 input tokens a second.
|
||||||
|
LIMIT = lambda n: [{"color": "transparent", "value": None}, {"color": "red", "value": n}]
|
||||||
|
p = ts("Against TypeSafe's limits", [prom('sum(rate(ipx_jev_requests_total{job="ipx"}[$__rate_interval]))', legend="calls a second"),
|
||||||
|
prom('sum(rate(ipx_jev_tokens_total{job="ipx", kind="input"}[$__rate_interval])) / 1000', ref="B", legend="thousand input tokens a second"),
|
||||||
|
prom('sum(rate(ipx_jev_paced_seconds_total{job="ipx"}[$__rate_interval]))', ref="C", legend="seconds waited for a turn, a second")],
|
||||||
|
16, w=8, desc="TypeSafe allows 80 calls and 100,000 input tokens a second; the red line is 80 on both scales (calls, and thousands of tokens). "
|
||||||
|
"Waiting for a turn means ipx is holding itself under them, and is the time a new Signal's backfill takes longer for it.")
|
||||||
|
p["fieldConfig"]["defaults"]["custom"]["thresholdsStyle"] = {"mode": "line"}
|
||||||
|
p["fieldConfig"]["defaults"]["thresholds"] = {"mode": "absolute", "steps": LIMIT(80)}
|
||||||
|
y += 8
|
||||||
|
# By person and whose key paid (#278): who the server's TypeSafe bill is for. An item shared on the
|
||||||
|
# server's key is split by each person's share of its questions.
|
||||||
|
# Counts from before #278 have no person; user!="" leaves them out rather than drawing " ( key)".
|
||||||
|
ts("Input tokens by person", [prom('sum by (user, key) (increase(ipx_jev_tokens_total{job="ipx", kind="input", user!=""}[$__rate_interval]))',
|
||||||
|
legend="{{user}} ({{key}} key)")],
|
||||||
|
0, w=8, bars=True, stack=True, desc="server is the server's TypeSafe key, what the server pays for; own is the person's own key.")
|
||||||
|
ts("Items judged by person", [prom('sum by (user, key) (increase(ipx_signals_judged_total{job="ipx", user!=""}[$__rate_interval]))', legend="{{user}} ({{key}} key)"),
|
||||||
|
prom('sum by (user) (increase(ipx_signals_hits_total{job="ipx", user!=""}[$__rate_interval]))', ref="B", legend="{{user}}: fit a Signal")],
|
||||||
|
8, w=8, bars=True)
|
||||||
|
def ptab(expr, ref):
|
||||||
|
return {"refId": ref, "datasource": PROM, "expr": expr, "instant": True, "format": "table"}
|
||||||
|
panel("table", "Who used what", 8, 8, 16,
|
||||||
|
[ptab('sum by (user, key) (increase(ipx_jev_tokens_total{job="ipx", kind="input", user!=""}[$__range]))', "A"),
|
||||||
|
ptab('sum by (user, key) (increase(ipx_jev_tokens_total{job="ipx", kind="output", user!=""}[$__range]))', "B"),
|
||||||
|
ptab('sum by (user, key) (increase(ipx_signals_judged_total{job="ipx", user!=""}[$__range]))', "C")],
|
||||||
|
description="Over the dashboard's time range: each person's input and output tokens and items judged, and whose key paid. "
|
||||||
|
"Icon picks are on the server's key, key checks on the person's own.",
|
||||||
|
transformations=[{"id": "merge", "options": {}},
|
||||||
|
{"id": "organize", "options": {"excludeByName": {"Time": True},
|
||||||
|
"renameByName": {"user": "Person", "key": "Key", "Value #A": "Input tokens",
|
||||||
|
"Value #B": "Output tokens", "Value #C": "Items judged"}}}],
|
||||||
|
options={"showHeader": True, "sortBy": [{"displayName": "Input tokens", "desc": True}]},
|
||||||
|
fieldConfig={"defaults": {"decimals": 0}, "overrides": []})
|
||||||
|
y += 8
|
||||||
|
|
||||||
# ---- ipx: the process, what it holds between requests and jobs (#194)
|
# ---- ipx: the process, what it holds between requests and jobs (#194)
|
||||||
row("ipx: process")
|
row("ipx: process")
|
||||||
# Open connections stay open, idle, for ten minutes after a burst, so open at the max is not
|
# 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).
|
# 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"),
|
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('min(min_over_time(ipx_db_connections{job="ipx", state="idle"}[$__interval]))', ref="B", legend="ready"),
|
||||||
prom('max(ipx_db_connections_max{job="ipx"})', ref="C", legend="most the pool opens")],
|
prom('max(ipx_db_connections_max{job="ipx"})', ref="C", legend="the pool")],
|
||||||
0, w=8, desc="In use reaching the most the pool opens means requests and scans are waiting for a connection. "
|
0, w=8, desc="The daemon keeps the whole pool open (#241), so in use and ready add up to the pool. In use reaching "
|
||||||
"Open connections stay open, idle, for ten minutes after a burst.")["fieldConfig"]["overrides"] = [
|
"the pool, ready at 0, means requests and scans are waiting for a connection (#251).")
|
||||||
{"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("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)
|
ts("Open files and sockets", [prom('ipx_process_open_fds{job="ipx"}', legend="open")], 16, w=8)
|
||||||
y += 8
|
y += 8
|
||||||
@@ -279,7 +347,7 @@ row("ipx-directory")
|
|||||||
stat("Snapshots", loki(f'sum(count_over_time({DIR} | message="snapshot" [$__range])) or vector(0)', instant=True), 0,
|
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).")
|
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,
|
stat("Feed checks", loki(f'sum(sum_over_time({DIR} | message="feeds checked" | unwrap checked [$__range])) or vector(0)', instant=True), 4,
|
||||||
desc="Each feed every six hours, 25 at a time.")
|
desc="Each feed every twelve hours, five at a time.")
|
||||||
stat("Failed feed checks", loki(f'sum(count_over_time({DIR} | message="feed check failed" [$__range])) or vector(0)', instant=True), 8,
|
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.")
|
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", loki(f'sum(count_over_time({DIR_HTTP} [$__range])) or vector(0)', instant=True), 12)
|
||||||
|
|||||||
12
package-lock.json
generated
@@ -1,11 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "ipx-web",
|
"name": "ipx-web",
|
||||||
|
"version": "1.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ipx-web",
|
"name": "ipx-web",
|
||||||
|
"version": "1.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "7.3.1",
|
||||||
"@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"
|
"hls.js": "1.6.19"
|
||||||
@@ -15,6 +18,15 @@
|
|||||||
"typescript": "^7.0.2"
|
"typescript": "^7.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fortawesome/fontawesome-free": {
|
||||||
|
"version": "7.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz",
|
||||||
|
"integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==",
|
||||||
|
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.63.0",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ipx-web",
|
"name": "ipx-web",
|
||||||
"version": "1.0.2",
|
"version": "1.2.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": {
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"typescript": "^7.0.2"
|
"typescript": "^7.0.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "7.3.1",
|
||||||
"@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"
|
"hls.js": "1.6.19"
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ module.exports = defineConfig({
|
|||||||
// production's Tempo and never opens production's database.
|
// production's Tempo and never opens production's database.
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: '',
|
OTEL_EXPORTER_OTLP_ENDPOINT: '',
|
||||||
IPX_DATABASE_URL: '',
|
IPX_DATABASE_URL: '',
|
||||||
|
// The server's TypeSafe key is the shell's: /src/.envrc's is a development key, for this.
|
||||||
|
// Without one, the tests of the server paying for Signals see that it cannot.
|
||||||
|
TYPESAFE_KEY: process.env.TYPESAFE_KEY ?? '',
|
||||||
|
IPX_TYPESAFE_URL: 'http://127.0.0.1:8792/jev',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
4
rustfmt.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Matches how the code was written by hand before rustfmt was adopted: long lines, and short
|
||||||
|
# expressions kept on one line.
|
||||||
|
max_width = 120
|
||||||
|
use_small_heuristics = "Max"
|
||||||
90
site/api.mjs
@@ -67,9 +67,11 @@ export const ipx = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'PATCH', path: '/api/me', slug: 'patch-me',
|
method: 'PATCH', path: '/api/me', slug: 'patch-me',
|
||||||
summary: 'Change the words that hide an item in every feed you read.',
|
summary: 'Change the words that hide an item in every feed you read, or your TypeSafe key for Signals.',
|
||||||
|
doc: '<p>A key is checked with TypeSafe, by one short question on it, before it is saved.</p>',
|
||||||
body: 'MePatch', returns: '204 No Content',
|
body: 'MePatch', returns: '204 No Content',
|
||||||
errors: [['400 Bad Request', 'More than 500 words, or one over 200 characters.']],
|
errors: [['400 Bad Request', 'More than 500 words, or one over 200 characters; or a key TypeSafe did not accept.'],
|
||||||
|
['502 Bad Gateway', 'TypeSafe could not be asked about the key just now. It is not saved.']],
|
||||||
example: ipxCurl('PATCH', '/api/me', '{"blocked":["sponsor","ad read"]}'),
|
example: ipxCurl('PATCH', '/api/me', '{"blocked":["sponsor","ad read"]}'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -115,7 +117,7 @@ export const ipx = {
|
|||||||
{
|
{
|
||||||
method: 'PATCH', path: '/api/feeds/{id}', slug: 'patch-feed',
|
method: 'PATCH', path: '/api/feeds/{id}', slug: 'patch-feed',
|
||||||
summary: 'Change your settings for a feed, or (an admin) the feed’s own.',
|
summary: 'Change your settings for a feed, or (an admin) the feed’s own.',
|
||||||
doc: '<p>Send only the fields to change. Which episodes you want, whether to download them, how many at a time, DeadAir and pinning are yours, and change for nobody else. The feed’s address, folder, schedule and category, and marking it shareable again, are the same for everyone and an admin’s to change.</p>',
|
doc: '<p>Send only the fields to change. Which episodes you want, whether to download them, how many at a time, DeadAir, pinning and keeping a show from an OPML are yours, and change for nobody else. The feed’s address, folder, schedule and category, and marking it shareable again, are the same for everyone and an admin’s to change.</p>',
|
||||||
pathParams: [['id', 'String', 'The feed’s id.']],
|
pathParams: [['id', 'String', 'The feed’s id.']],
|
||||||
body: 'FeedPatch', returns: '204 No Content',
|
body: 'FeedPatch', returns: '204 No Content',
|
||||||
errors: [['400 Bad Request', 'A value out of range, such as a DeadAir pause that is not 200, 300, 400 or 500.'], ['403 Forbidden', 'A field only an admin may change.'], NOT_SUBSCRIBED],
|
errors: [['400 Bad Request', 'A value out of range, such as a DeadAir pause that is not 200, 300, 400 or 500.'], ['403 Forbidden', 'A field only an admin may change.'], NOT_SUBSCRIBED],
|
||||||
@@ -166,7 +168,7 @@ export const ipx = {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
method: 'GET', path: '/api/feeds/{id}/entries', slug: 'get-feed-entries',
|
method: 'GET', path: '/api/feeds/{id}/entries', slug: 'get-feed-entries',
|
||||||
summary: 'A page of one feed’s episodes, newest first unless sorted.',
|
summary: 'A page of one feed’s episodes, newest first unless sorted. For an OPML list or a Patreon creator, those of the shows in it you subscribe to, together.',
|
||||||
pathParams: [['id', 'String', 'The feed’s id.']],
|
pathParams: [['id', 'String', 'The feed’s id.']],
|
||||||
query: 'Page', returns: 'EntryPage', errors: [NOT_SUBSCRIBED],
|
query: 'Page', returns: 'EntryPage', errors: [NOT_SUBSCRIBED],
|
||||||
example: ipxCurl('GET', '/api/feeds/99-invisible/entries?filter=unread&limit=10'),
|
example: ipxCurl('GET', '/api/feeds/99-invisible/entries?filter=unread&limit=10'),
|
||||||
@@ -205,6 +207,54 @@ export const ipx = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'signals', name: 'signals', title: 'Signals',
|
||||||
|
summary: 'Subjects picked out of every feed you subscribe to by TypeSafe.',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/signals', slug: 'get-signals',
|
||||||
|
summary: 'Your Signals, each with its items and unread counted.',
|
||||||
|
returns: 'Vec<Signal>',
|
||||||
|
example: ipxCurl('GET', '/api/signals'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/api/signals', slug: 'post-signals',
|
||||||
|
summary: 'Make a Signal. It looks back over the last week (an admin’s setting), then at each new item.',
|
||||||
|
doc: '<p>Each item is one call to TypeSafe: on the server’s key when an admin has chosen that (<code>signals_on_server</code>), otherwise on yours, saved with <a href="/docs/api/ipx/auth/patch-me"><code>PATCH /api/me</code></a>. Without a key, nothing is picked out.</p>',
|
||||||
|
body: 'SignalBody', returns: '{ id: i64 }',
|
||||||
|
errors: [['400 Bad Request', 'No name or one over 100 characters, not 1 to 50 words, a description over 500 characters, or an icon that is not a style and a name.']],
|
||||||
|
example: ipxCurl('POST', '/api/signals', '{"name":"Formula 1","keywords":["formula 1","f1"],"description":"Grand prix racing, not road cars."}'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'PATCH', path: '/api/signals/{id}', slug: 'patch-signal',
|
||||||
|
summary: 'Change a Signal. A new name, words or description asks again about what it picked out, and about new items as they come; a new icon asks nothing.',
|
||||||
|
pathParams: [['id', 'i64', 'The Signal’s id.']],
|
||||||
|
body: 'SignalBody', returns: '204 No Content',
|
||||||
|
errors: [['400 Bad Request', 'As for a new Signal.'], ['404 Not Found', 'No Signal of yours has that id.']],
|
||||||
|
example: ipxCurl('PATCH', '/api/signals/3', '{"keywords":["f1","grand prix"]}'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE', path: '/api/signals/{id}', slug: 'delete-signal',
|
||||||
|
summary: 'Delete a Signal. Its items stay in their feeds.',
|
||||||
|
pathParams: [['id', 'i64', 'The Signal’s id.']],
|
||||||
|
returns: '204 No Content', errors: [['404 Not Found', 'No Signal of yours has that id.']],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET', path: '/api/signals/{id}/entries', slug: 'get-signal-entries',
|
||||||
|
summary: 'A page of the items picked out for a Signal, as a feed’s list is.',
|
||||||
|
pathParams: [['id', 'i64', 'The Signal’s id.']],
|
||||||
|
query: 'Page', returns: 'EntryPage', errors: [['404 Not Found', 'No Signal of yours has that id.']],
|
||||||
|
example: ipxCurl('GET', '/api/signals/3/entries?filter=unread&limit=10'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST', path: '/api/signals/icon', slug: 'post-signal-icon',
|
||||||
|
summary: 'Have TypeSafe choose a Signal’s icon from the ones offered, on the server’s TypeSafe key.',
|
||||||
|
body: 'IconAsk', returns: '{ icon: String }',
|
||||||
|
errors: [['400 Bad Request', 'No icons, more than 60, or one that is not a style and a name.'],
|
||||||
|
['404 Not Found', 'The server has no TypeSafe key.'], ['502 Bad Gateway', 'TypeSafe could not choose just now.']],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'files', name: 'files', title: 'Files',
|
id: 'files', name: 'files', title: 'Files',
|
||||||
summary: 'Downloading, deleting and playing an episode’s file.',
|
summary: 'Downloading, deleting and playing an episode’s file.',
|
||||||
@@ -379,8 +429,28 @@ export const ipx = {
|
|||||||
['sign_out', 'Option<String>', 'Where signing out should go, for someone single sign-on signed in.'],
|
['sign_out', 'Option<String>', 'Where signing out should go, for someone single sign-on signed in.'],
|
||||||
['deadair_saved', 'i64', 'Seconds DeadAir has saved you, all told.'],
|
['deadair_saved', 'i64', 'Seconds DeadAir has saved you, all told.'],
|
||||||
['version', 'String', 'ipx’s version.'],
|
['version', 'String', 'ipx’s version.'],
|
||||||
|
['typesafe_key_set', 'bool', 'You have a TypeSafe key saved for Signals. The key itself is never sent back.'],
|
||||||
|
['signals', 'String', 'Whose key Signals are judged on: <code>server</code>, for everyone, or <code>own</code>, each person’s.'],
|
||||||
|
['signal_days', 'u64', 'How many days back a new Signal reads.'],
|
||||||
|
] },
|
||||||
|
{ name: 'MePatch', doc: 'What <code>PATCH /api/me</code> changes.', fields: [['blocked', 'Option<Vec<String>>', 'Replaces the list.'],
|
||||||
|
['typesafe_key', 'Option<Option<String>>', 'Your TypeSafe key for Signals, checked with TypeSafe and then saved; <code>null</code> or empty forgets it.']] },
|
||||||
|
{ name: 'Signal', doc: 'A Signal in your sidebar.', fields: [
|
||||||
|
['id', 'i64', ''], ['name', 'String', ''], ['keywords', 'Vec<String>', 'The words it listens for.'],
|
||||||
|
['description', 'Option<String>', 'What you mean, in your own words.'],
|
||||||
|
['icon', 'Option<String>', 'A Font Awesome icon, such as <code>solid/robot</code>; none shows its initials.'],
|
||||||
|
['items', 'i64', 'Items picked out that are still in a feed you subscribe to.'], ['unread', 'i64', 'Those of them not read.'],
|
||||||
|
] },
|
||||||
|
{ name: 'SignalBody', doc: 'A Signal to make, or what to change of one. Leave out what stays.', fields: [
|
||||||
|
['name', 'Option<String>', 'Up to 100 characters; needed for a new one.'],
|
||||||
|
['keywords', 'Option<Vec<String>>', '1 to 50 words, each up to 200 characters; needed for a new one.'],
|
||||||
|
['description', 'Option<Option<String>>', 'Up to 500 characters; <code>null</code> or empty clears it.'],
|
||||||
|
['icon', 'Option<Option<String>>', '<code>solid/</code>, <code>regular/</code> or <code>brands/</code> and a name; <code>null</code> or empty clears it.'],
|
||||||
|
] },
|
||||||
|
{ name: 'IconAsk', doc: 'The icons to choose a Signal’s from.', fields: [
|
||||||
|
['name', 'String', ''], ['keywords', 'Vec<String>', ''], ['description', 'Option<String>', ''],
|
||||||
|
['candidates', 'Vec<(String, String)>', '1 to 60 of <code>[icon, label]</code>, the label its name and search terms.'],
|
||||||
] },
|
] },
|
||||||
{ name: 'MePatch', doc: 'What <code>PATCH /api/me</code> changes.', fields: [['blocked', 'Option<Vec<String>>', 'Replaces the list.']] },
|
|
||||||
{ name: 'Token', doc: 'An API token, as listed.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['created', 'i64', ''], ['last_used', 'Option<i64>', '']] },
|
{ name: 'Token', doc: 'An API token, as listed.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['created', 'i64', ''], ['last_used', 'Option<i64>', '']] },
|
||||||
{ name: 'NewToken', doc: 'A token to make.', fields: [['name', 'String', 'What it is for, to know it by when revoking it.']] },
|
{ name: 'NewToken', doc: 'A token to make.', fields: [['name', 'String', 'What it is for, to know it by when revoking it.']] },
|
||||||
{ name: 'NewTokenMade', doc: 'A token just made: the only time it is shown.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['token', 'String', 'Begins <code>ipx_</code>. Send it as <code>Authorization: Bearer</code>.']] },
|
{ name: 'NewTokenMade', doc: 'A token just made: the only time it is shown.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['token', 'String', 'Begins <code>ipx_</code>. Send it as <code>Authorization: Bearer</code>.']] },
|
||||||
@@ -407,6 +477,7 @@ export const ipx = {
|
|||||||
['entries', 'i64', ''], ['downloaded', 'i64', ''], ['unread', 'i64', ''],
|
['entries', 'i64', ''], ['downloaded', 'i64', ''], ['unread', 'i64', ''],
|
||||||
['subscribers', 'i64', 'People on this server, you included.'],
|
['subscribers', 'i64', 'People on this server, you included.'],
|
||||||
['pinned', 'bool', 'At the top of your list.'],
|
['pinned', 'bool', 'At the top of your list.'],
|
||||||
|
['kept', 'bool', 'From an OPML, and yours to keep as a feed of its own should the OPML drop it or you leave the OPML.'],
|
||||||
['private', 'bool', 'Kept out of the directory.'],
|
['private', 'bool', 'Kept out of the directory.'],
|
||||||
['private_by_address', 'bool', 'Private whatever it is marked: a login, or a key in its address.'],
|
['private_by_address', 'bool', 'Private whatever it is marked: a login, or a key in its address.'],
|
||||||
] },
|
] },
|
||||||
@@ -423,6 +494,7 @@ export const ipx = {
|
|||||||
['allow_explicit', 'Option<bool>', 'Yours.'], ['auto_download', 'Option<bool>', 'Yours.'],
|
['allow_explicit', 'Option<bool>', 'Yours.'], ['auto_download', 'Option<bool>', 'Yours.'],
|
||||||
['max_new_per_check', 'Option<Option<usize>>', 'Yours; <code>null</code> for the server’s.'],
|
['max_new_per_check', 'Option<Option<usize>>', 'Yours; <code>null</code> for the server’s.'],
|
||||||
['pinned', 'Option<bool>', 'Yours.'],
|
['pinned', 'Option<bool>', 'Yours.'],
|
||||||
|
['kept', 'Option<bool>', 'Yours: keep a show from an OPML should the OPML drop it.'],
|
||||||
['deadair', 'Option<Option<bool>>', 'Yours; <code>null</code> follows its OPML list’s.'],
|
['deadair', 'Option<Option<bool>>', 'Yours; <code>null</code> follows its OPML list’s.'],
|
||||||
['deadair_pause', 'Option<i64>', 'Yours: 200, 300, 400 or 500.'],
|
['deadair_pause', 'Option<i64>', 'Yours: 200, 300, 400 or 500.'],
|
||||||
['private', 'Option<bool>', 'Anyone subscribed may mark it private; only an admin unmarks it.'],
|
['private', 'Option<bool>', 'Anyone subscribed may mark it private; only an admin unmarks it.'],
|
||||||
@@ -447,6 +519,8 @@ export const ipx = {
|
|||||||
['guid', 'String', ''], ['feed_id', 'String', ''], ['title', 'Option<String>', ''], ['link', 'Option<String>', ''],
|
['guid', 'String', ''], ['feed_id', 'String', ''], ['title', 'Option<String>', ''], ['link', 'Option<String>', ''],
|
||||||
['published', 'Option<i64>', ''],
|
['published', 'Option<i64>', ''],
|
||||||
['description', 'Option<String>', 'Show notes, HTML, already sanitized.'],
|
['description', 'Option<String>', 'Show notes, HTML, already sanitized.'],
|
||||||
|
['markdown', 'Option<String>', 'The item’s Markdown as written, when its feed sends Markdown beside its HTML (RSS’s source:markdown).'],
|
||||||
|
['markdown_html', 'Option<String>', 'That Markdown made HTML, sanitized as description is.'],
|
||||||
['read', 'bool', ''], ['flagged', 'bool', 'Kept: its file is never cleared to save space.'],
|
['read', 'bool', ''], ['flagged', 'bool', 'Kept: its file is never cleared to save space.'],
|
||||||
['image', 'Option<String>', ''], ['duration', 'Option<i64>', 'Seconds.'],
|
['image', 'Option<String>', ''], ['duration', 'Option<i64>', 'Seconds.'],
|
||||||
['episode', 'Option<i64>', ''], ['season', 'Option<i64>', ''],
|
['episode', 'Option<i64>', ''], ['season', 'Option<i64>', ''],
|
||||||
@@ -493,6 +567,7 @@ export const ipx = {
|
|||||||
['download_error', '{ feed, enclosure, msg, url }', ''],
|
['download_error', '{ feed, enclosure, msg, url }', ''],
|
||||||
['deadair_done', '{ feed, enclosure, saved_s }', 'DeadAir finished a file.'],
|
['deadair_done', '{ feed, enclosure, saved_s }', 'DeadAir finished a file.'],
|
||||||
['scan_done', '{ feeds }', 'A check finished; how many feeds it looked at.'],
|
['scan_done', '{ feeds }', 'A check finished; how many feeds it looked at.'],
|
||||||
|
['signal_hits', '{ user, hits }', 'Items were picked out for someone’s Signals; theirs to fetch again.'],
|
||||||
] },
|
] },
|
||||||
{ name: 'Settings', doc: 'The server’s settings.', fields: [
|
{ name: 'Settings', doc: 'The server’s settings.', fields: [
|
||||||
['schedule', 'String', 'How often feeds are checked, such as <code>every 60m</code>.'], ['every_mins', 'u64', 'The same in minutes.'],
|
['schedule', 'String', 'How often feeds are checked, such as <code>every 60m</code>.'], ['every_mins', 'u64', 'The same in minutes.'],
|
||||||
@@ -502,12 +577,17 @@ export const ipx = {
|
|||||||
['max_age_days', 'u64', '0 for no limit.'], ['art_cache_mb', 'u64', 'Artwork kept.'],
|
['max_age_days', 'u64', '0 for no limit.'], ['art_cache_mb', 'u64', 'Artwork kept.'],
|
||||||
['directory_url', 'String', ''], ['directory_token_set', 'bool', 'The token itself is never sent back.'],
|
['directory_url', 'String', ''], ['directory_token_set', 'bool', 'The token itself is never sent back.'],
|
||||||
['directory_share', 'bool', ''],
|
['directory_share', 'bool', ''],
|
||||||
|
['signals_on_server', 'bool', 'Everyone’s Signals are judged on the server’s TypeSafe key, not each person’s.'],
|
||||||
|
['signal_days', 'u64', 'How many days back a new Signal reads; 7 unless an admin sets it.'],
|
||||||
|
['typesafe_server_key', 'bool', 'The server has a TypeSafe key (<code>TYPESAFE_KEY</code>); without one, <code>signals_on_server</code> changes nothing.'],
|
||||||
] },
|
] },
|
||||||
{ name: 'SettingsPatch', doc: 'What <code>PATCH /api/settings</code> changes. Leave out what stays.', fields: [
|
{ name: 'SettingsPatch', doc: 'What <code>PATCH /api/settings</code> changes. Leave out what stays.', fields: [
|
||||||
['schedule', 'Option<String>', ''], ['max_new_per_check', 'Option<usize>', ''], ['media_types', 'Option<Vec<String>>', ''],
|
['schedule', 'Option<String>', ''], ['max_new_per_check', 'Option<usize>', ''], ['media_types', 'Option<Vec<String>>', ''],
|
||||||
['max_total_gb', 'Option<f64>', ''], ['max_age_days', 'Option<u64>', ''], ['art_cache_mb', 'Option<u64>', ''],
|
['max_total_gb', 'Option<f64>', ''], ['max_age_days', 'Option<u64>', ''], ['art_cache_mb', 'Option<u64>', ''],
|
||||||
['directory_url', 'Option<String>', 'Empty for no directory.'], ['directory_token', 'Option<String>', 'Replaces it; empty clears it.'],
|
['directory_url', 'Option<String>', 'Empty for no directory.'], ['directory_token', 'Option<String>', 'Replaces it; empty clears it.'],
|
||||||
['directory_share', 'Option<bool>', ''],
|
['directory_share', 'Option<bool>', ''],
|
||||||
|
['signals_on_server', 'Option<bool>', 'Judge everyone’s Signals on the server’s TypeSafe key.'],
|
||||||
|
['signal_days', 'Option<u64>', '1 to 365.'],
|
||||||
] },
|
] },
|
||||||
{ name: 'UserRow', doc: 'An account.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['admin', 'bool', ''], ['password', 'bool', 'Has one; without, it signs in only by single sign-on.'], ['created', 'Option<i64>', ''], ['last_login', 'Option<i64>', '']] },
|
{ name: 'UserRow', doc: 'An account.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['admin', 'bool', ''], ['password', 'bool', 'Has one; without, it signs in only by single sign-on.'], ['created', 'Option<i64>', ''], ['last_login', 'Option<i64>', '']] },
|
||||||
{ name: 'NewUser', doc: '', fields: [['name', 'String', 'No commas, semicolons or line breaks.'], ['password', 'String', 'Empty for single sign-on only.'], ['admin', 'bool', '']] },
|
{ name: 'NewUser', doc: '', fields: [['name', 'String', 'No commas, semicolons or line breaks.'], ['password', 'String', 'Empty for single sign-on only.'], ['admin', 'bool', '']] },
|
||||||
|
|||||||
@@ -29,4 +29,4 @@
|
|||||||
bind = "0.0.0.0:8100"
|
bind = "0.0.0.0:8100"
|
||||||
database_url_env = "DIRECTORY_DATABASE_URL"</code></pre>
|
database_url_env = "DIRECTORY_DATABASE_URL"</code></pre>
|
||||||
<pre><code>docker exec ipx-directory ipx-directory token new my-server --access rw</code></pre>
|
<pre><code>docker exec ipx-directory ipx-directory token new my-server --access rw</code></pre>
|
||||||
<p>That prints the token, to give to the server, and the lines to add to <code>directory.toml</code>, which keeps only its SHA-256. The directory reads the file again within a minute, so adding or revoking a token needs no restart. It checks each podcast every six hours.</p>
|
<p>That prints the token, to give to the server, and the lines to add to <code>directory.toml</code>, which keeps only its SHA-256. The directory reads the file again within a minute, so adding or revoking a token needs no restart. It checks each podcast every twelve hours.</p>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ docker build -f Dockerfile.web -t ipx-web .</code></pre>
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr><td><code>IPX_DATABASE_URL</code></td><td>A <code>postgres://user:password@host:5432/database</code> address, to keep everything in Postgres instead of SQLite</td></tr>
|
<tr><td><code>IPX_DATABASE_URL</code></td><td>A <code>postgres://user:password@host:5432/database</code> address, to keep everything in Postgres instead of SQLite</td></tr>
|
||||||
<tr><td><code>PUID</code>, <code>PGID</code></td><td>The user and group that own the files ipx writes</td></tr>
|
<tr><td><code>PUID</code>, <code>PGID</code></td><td>The user and group that own the files ipx writes</td></tr>
|
||||||
|
<tr><td><code>TYPESAFE_KEY</code></td><td>The server's own <a href="https://typesafe.ai">typesafe.ai</a> API key: it picks a podcast's Directory category and a Signal's icon, and, when an admin turns it on, pays for everyone's Signals</td></tr>
|
||||||
<tr><td><code>IPX_LOG_FORMAT</code></td><td><code>json</code> logs one JSON object a line, for Loki and the like</td></tr>
|
<tr><td><code>IPX_LOG_FORMAT</code></td><td><code>json</code> logs one JSON object a line, for Loki and the like</td></tr>
|
||||||
<tr><td><code>OTEL_EXPORTER_OTLP_ENDPOINT</code></td><td>Sends traces of requests, checks and downloads to an OpenTelemetry collector; set <code>WEB_TRACING=on</code> on ipx-web too</td></tr>
|
<tr><td><code>OTEL_EXPORTER_OTLP_ENDPOINT</code></td><td>Sends traces of requests, checks and downloads to an OpenTelemetry collector; set <code>WEB_TRACING=on</code> on ipx-web too</td></tr>
|
||||||
<tr><td><code>IPX_UPSTREAM</code></td><td>On ipx-web: where ipx is, <code>ipx:8099</code> by default</td></tr>
|
<tr><td><code>IPX_UPSTREAM</code></td><td>On ipx-web: where ipx is, <code>ipx:8099</code> by default</td></tr>
|
||||||
|
|||||||
@@ -56,17 +56,7 @@
|
|||||||
|
|
||||||
<!-- Each feature beside the part of the app that shows it, cut from a scratch iPX on public
|
<!-- 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. -->
|
podcasts, in a navy frame like the dial's. -->
|
||||||
<section class="does" aria-labelledby="does">
|
<section class="does" aria-label="What it 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="feature">
|
||||||
<div class="say">
|
<div class="say">
|
||||||
@@ -90,8 +80,8 @@
|
|||||||
episode ends sooner. CSS only; the page runs no script of its own. -->
|
episode ends sooner. CSS only; the page runs no script of its own. -->
|
||||||
<div class="feature">
|
<div class="feature">
|
||||||
<div class="say">
|
<div class="say">
|
||||||
<h3>Cut the dead air</h3>
|
<h3>All talk, no 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>
|
<p>Radio has a name for silence on air: dead air. Turn on DeadAir for a show and its long silences shrink to a pause as short as you like. Everyone still talks at their own pace; the episode just ends sooner.</p>
|
||||||
</div>
|
</div>
|
||||||
<figure class="frame deadair" role="img" aria-label="A waveform whose silent gaps shrink to short pauses, so the episode ends sooner.">
|
<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">
|
<div class="wave" aria-hidden="true">
|
||||||
@@ -105,6 +95,15 @@
|
|||||||
</figure>
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="feature">
|
||||||
|
<div class="say">
|
||||||
|
<h3>Tune out the noise</h3>
|
||||||
|
<p>Your feeds bring in more than anyone has time for. A Signal listens across all of them for one subject: give it a name, the words to listen for, and a sentence saying what you mean if the words are not enough. What fits lands in a list of its own, starting with the last week, and it keeps listening. To use Signals you will need an API key from <a href="https://typesafe.ai">typesafe.ai</a>, unless your server's admin provides one.</p>
|
||||||
|
</div>
|
||||||
|
<figure class="frame"><img class="cut" src="/shots/signals.webp" width="874" height="398" loading="lazy"
|
||||||
|
alt="A Signal called Space, picked out of the noise of eight feeds: episodes and posts about galaxies, Artemis II, Mars and a rocket launch from Hackaday, Universe Today, NASA's Houston We Have a Podcast and Ars Technica."></figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="feature">
|
<div class="feature">
|
||||||
<div class="say">
|
<div class="say">
|
||||||
<h3>See what others listen to</h3>
|
<h3>See what others listen to</h3>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB |
BIN
site/shots/signals.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
@@ -64,12 +64,15 @@ h1{margin:0;color:var(--navy);font-weight:900;font-size:clamp(6.5rem,24vw,15rem)
|
|||||||
|
|
||||||
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}
|
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}
|
.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,
|
/* Each feature on one row: what it does beside the part of the app that shows it,
|
||||||
framed in the dial's navy at no more than its own size. */
|
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{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 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}
|
.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}
|
/* Every other row the picture comes first, so the page zigzags rather than running down one side. */
|
||||||
|
.feature:nth-of-type(even){grid-template-columns:minmax(0,1fr) minmax(0,22rem)}
|
||||||
|
.feature:nth-of-type(even) .say{order:2}
|
||||||
|
.frame{margin:0;justify-self:center;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}
|
.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
|
/* 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. */
|
in the needle's orange and shrinks to a pause, and the end comes in from the right. */
|
||||||
@@ -175,7 +178,8 @@ footer p{margin:0}
|
|||||||
main,.bar,footer{padding-inline:1rem}
|
main,.bar,footer{padding-inline:1rem}
|
||||||
.bar nav{gap:1rem}
|
.bar nav{gap:1rem}
|
||||||
.hero{padding-block:1.5rem 2rem}
|
.hero{padding-block:1.5rem 2rem}
|
||||||
.feature{grid-template-columns:minmax(0,1fr);gap:1.25rem;padding-block:1.75rem}
|
.feature,.feature:nth-of-type(even){grid-template-columns:minmax(0,1fr);gap:1.25rem;padding-block:1.75rem}
|
||||||
|
.feature:nth-of-type(even) .say{order:0}
|
||||||
/* A wide crop at this width is too small to read: its left part, larger. */
|
/* 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{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.player{aspect-ratio:2.4/1}
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ export const options = {
|
|||||||
'http_req_duration{name:all-entries}': p95(50, 100),
|
'http_req_duration{name:all-entries}': p95(50, 100),
|
||||||
'http_req_duration{name:feed-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:search}': p95(60, 100),
|
||||||
'http_req_duration{name:directory}': p95(250, 200),
|
|
||||||
'http_req_duration{name:listed}': p95(300, 200),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,11 +42,7 @@ export default function () {
|
|||||||
const found = http.get(`${BASE}/api/entries?q=weather&limit=50`, as(u, 'search'));
|
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 });
|
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'));
|
// Not the Directory: since #204 it is ipx-directory's, which this harness does not start (#245).
|
||||||
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);
|
sleep(1 + Math.random() * 2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,7 +189,8 @@ async function seed() {
|
|||||||
}
|
}
|
||||||
// The media test's listener takes the feeds with files. They may be downloaded already: the
|
// 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.
|
// first start subscribes the admin to the whole catalogue, so the first scan fetched them.
|
||||||
for (let n = 0; n < MEDIA; n++) await api('listener', `/api/popular/${feedId(n)}`, { method: 'POST' });
|
// By OPML as the others: /api/popular asks the directory, which this harness does not start (#245).
|
||||||
|
await api('listener', '/api/opml', { method: 'POST', body: JSON.stringify({ xml: opml(Array.from({ length: MEDIA }, (_, n) => n)) }) });
|
||||||
for (let t = Date.now(); ; await new Promise(r => setTimeout(r, 500))) {
|
for (let t = Date.now(); ; await new Promise(r => setTimeout(r, 500))) {
|
||||||
const got = await api('listener', '/api/entries?filter=downloaded&limit=50');
|
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 (got.entries.flatMap(e => e.enclosures).filter(x => x.path).length >= MEDIA) break;
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const drive = PAGE === 'admin.html' ? [
|
|||||||
['keysModal', () => ctx.keysModal()],
|
['keysModal', () => ctx.keysModal()],
|
||||||
// `const S` is not reachable from here: top-level const/let do not become properties
|
// `const S` is not reachable from here: top-level const/let do not become properties
|
||||||
// of a vm context the way var and function declarations do.
|
// of a vm context the way var and function declarations do.
|
||||||
['renderGroup', () => ctx.renderGroup(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])],
|
['groupHead', () => ctx.groupHead(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])],
|
||||||
];
|
];
|
||||||
for (const [name, fn] of drive) {
|
for (const [name, fn] of drive) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -125,8 +125,98 @@ test("a file plays from DeadAir's playlist when it has one", () => {
|
|||||||
assert.equal(run(`mediaSrc({ id: 7, deadair: 0 })`), '/media/7/deadair.m3u8');
|
assert.equal(run(`mediaSrc({ id: 7, deadair: 0 })`), '/media/7/deadair.m3u8');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('kept lists: the newest few dozen, and a changed feed forgets its own and All Subscriptions\'', () => {
|
||||||
|
run('lists.clear()');
|
||||||
|
for (let i = 0; i < 45; i++) run(`keepList(listKey('f${i}'), {total: 0, entries: []})`);
|
||||||
|
assert.equal(run('lists.size'), 40, 'never more than LISTS_KEPT');
|
||||||
|
assert.equal(run(`lists.has(listKey('f0'))`), false, 'the oldest go first');
|
||||||
|
run(`keepList(listKey('f10'), {total: 1, entries: []})`);
|
||||||
|
run(`keepList(listKey(':all'), {total: 0, entries: []}); keepList(listKey(':all', 'unread'), {total: 0, entries: []})`);
|
||||||
|
run(`forgetLists('f10')`);
|
||||||
|
assert.equal(run(`lists.has(listKey('f10'))`), false);
|
||||||
|
assert.equal(run(`lists.has(listKey(':all')) || lists.has(listKey(':all', 'unread'))`), false, 'All Subscriptions holds its items too');
|
||||||
|
assert.equal(run(`lists.has(listKey('f11'))`), true, 'the rest stay');
|
||||||
|
});
|
||||||
|
|
||||||
test('a Patreon feed is told by its address', () => {
|
test('a Patreon feed is told by its address', () => {
|
||||||
assert.equal(run(`isPatreon({ url: 'https://www.patreon.com/rss/show?auth=x' })`), true);
|
assert.equal(run(`isPatreon({ url: 'https://www.patreon.com/rss/show?auth=x' })`), true);
|
||||||
assert.equal(run(`isPatreon({ url: 'https://example.com/feed' })`), false);
|
assert.equal(run(`isPatreon({ url: 'https://example.com/feed' })`), false);
|
||||||
assert.equal(run('isPatreon(null)'), false);
|
assert.equal(run('isPatreon(null)'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("All Subscriptions' unread count leaves out feeds hidden from it, and an OPML's shows with it", () => {
|
||||||
|
run(`S.feeds = [
|
||||||
|
{id: 'o', group: null, unread: 0, hide_from_all: true},
|
||||||
|
{id: 'a', group: 'o', unread: 2, hide_from_all: false},
|
||||||
|
{id: 'c', group: null, unread: 3, hide_from_all: true},
|
||||||
|
{id: 'd', group: null, unread: 5, hide_from_all: false}]`);
|
||||||
|
assert.equal(run('unreadInAll()'), 5);
|
||||||
|
run(`S.feeds[0].hide_from_all = false`);
|
||||||
|
assert.equal(run('unreadInAll()'), 7);
|
||||||
|
run(`S.feeds = []`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Signal is a place of its own, found by its id', () => {
|
||||||
|
run(`S.signals = [{id: 7, name: 'F1', keywords: ['f1'], unread: 2}]`);
|
||||||
|
assert.equal(run(`signalId(':signal:7')`), 7);
|
||||||
|
assert.equal(run(`signalId(':all')`), null);
|
||||||
|
assert.equal(run(`signalId('signal-7')`), null, 'a feed id is never a Signal');
|
||||||
|
assert.equal(run(`signalOf(':signal:7').name`), 'F1');
|
||||||
|
assert.equal(run(`signalOf(':signal:8')`), undefined);
|
||||||
|
assert.match(run(`listUrl(':signal:7')`), /^\/api\/signals\/7\/entries\?/);
|
||||||
|
run(`S.signals = []`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the icon picker finds icons by the start of their words, the one named it first', () => {
|
||||||
|
const all = JSON.stringify([
|
||||||
|
['solid/caret-down', 'Caret Down', 'arrow'], ['solid/scarf', 'Scarf', 'clothing'], ['solid/taxi', 'Taxi', 'cab car'],
|
||||||
|
['solid/car-side', 'Car Side', 'auto vehicle'], ['solid/car', 'Car', 'auto vehicle'], ['solid/robot', 'Robot', 'ai bot machine'],
|
||||||
|
['brands/apple', 'Apple', 'fruit ios mac'],
|
||||||
|
]);
|
||||||
|
const ids = q => JSON.parse(run(`JSON.stringify(iconMatches(${all}, ${JSON.stringify(q)}, 10).map(i => i[0]))`));
|
||||||
|
// Named car, then car as a word of the name, then as a search term, then caret; never scarf.
|
||||||
|
assert.deepEqual(ids('car'), ['solid/car', 'solid/car-side', 'solid/taxi', 'solid/caret-down']);
|
||||||
|
assert.deepEqual(ids('ai'), ['solid/robot'], 'search terms count');
|
||||||
|
assert.deepEqual(ids('mac fruit'), ['brands/apple'], 'every word');
|
||||||
|
assert.equal(run(`iconMatches(${all}, '', 2).length`), 2, 'nothing typed is the first few');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editing a Signal sends only what changed, so a new icon asks Jev nothing again', () => {
|
||||||
|
const sg = `{id: 1, name: 'F1', keywords: ['f1'], description: null, icon: null, unread: 0}`;
|
||||||
|
assert.deepEqual(JSON.parse(run(`JSON.stringify(signalChanges(${sg}, {name: 'F1', keywords: ['f1'], description: null, icon: 'solid/car'}))`)),
|
||||||
|
{ icon: 'solid/car' });
|
||||||
|
assert.deepEqual(JSON.parse(run(`JSON.stringify(signalChanges(${sg}, {name: 'F1', keywords: ['f1', 'gp'], description: null, icon: null}))`)),
|
||||||
|
{ keywords: ['f1', 'gp'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Signal draws its icon only when it is a style and a plain name', () => {
|
||||||
|
assert.match(run(`signalTile({name: 'F1', icon: 'solid/flag-checkered'})`), /url\('\/icons\/solid\/flag-checkered\.svg'\)/);
|
||||||
|
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: "solid/x');background:url(evil"})`), /evil/);
|
||||||
|
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: null})`), /ficon/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Jev is offered the icons found for each word of a Signal', () => {
|
||||||
|
const all = JSON.stringify([
|
||||||
|
['solid/robot', 'Robot', 'ai bot machine'], ['solid/code', 'Code', 'programming brackets'],
|
||||||
|
['solid/car', 'Car', 'auto vehicle'], ['solid/the-red-yeti', 'The Red Yeti', ''],
|
||||||
|
]);
|
||||||
|
const ids = JSON.parse(run(`JSON.stringify(iconCandidates(${all}, 'AI Development', ['programming'], 'The tools', 40).map(c => c[0]))`));
|
||||||
|
assert.deepEqual(ids, ['solid/robot', 'solid/code'], 'each word its icons, "the" none');
|
||||||
|
const brands = JSON.stringify([
|
||||||
|
['brands/mandalorian', 'Mandalorian', 'disney plus helmet star wars'], ['brands/apple', 'Apple', 'fruit ios mac'],
|
||||||
|
['solid/fort-awesome', 'Fort', 'castle'], ['solid/apple-whole', 'Apple Whole', 'fruit'],
|
||||||
|
]);
|
||||||
|
const offered = q => JSON.parse(run(`JSON.stringify(iconCandidates(${brands}, ${JSON.stringify(q)}, [], null, 40).map(c => c[0]))`));
|
||||||
|
assert.deepEqual(offered('Disney castle'), ['solid/fort-awesome'], 'a brand only by its own name, never its search terms (#264)');
|
||||||
|
assert.deepEqual(offered('Apple'), ['brands/apple', 'solid/apple-whole']);
|
||||||
|
assert.equal(run(`signalCounts({items: 12, unread: 1})`), '12 items · 1 unread');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Signals are everyone\'s on the server\'s key, and yours on your own once a key is saved', () => {
|
||||||
|
const on = me => { run(`S.me = ${JSON.stringify(me)}`); return run('signalsOn()'); };
|
||||||
|
assert.equal(on({ signals: 'server', typesafe_key_set: false }), true);
|
||||||
|
assert.equal(on({ signals: 'own', typesafe_key_set: true }), true);
|
||||||
|
assert.equal(on({ signals: 'own', typesafe_key_set: false }), false);
|
||||||
|
assert.equal(on(null), false);
|
||||||
|
run('S.me = null');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,38 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
const { TOKEN } = require('./global-setup');
|
const { TOKEN } = require('./global-setup');
|
||||||
|
|
||||||
|
// Signals' tests run only when the Signals code has changed since each last passed: they spend the
|
||||||
|
// TypeSafe key (#271). The Signals code is signals.rs and signals.ts whole, and the lines naming
|
||||||
|
// Signals, TypeSafe or Jev anywhere else in the daemon and the page; with the test's own source,
|
||||||
|
// hashed, and kept under target/ once the test passes. IPX_SIGNALS_TESTS=1 runs them anyway.
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const ROOT = path.join(__dirname, '../..');
|
||||||
|
const SIGNALS_CODE = (() => {
|
||||||
|
const h = require('node:crypto').createHash('sha256');
|
||||||
|
for (const dir of ['crates/ipx/src', 'web/src']) {
|
||||||
|
for (const f of fs.readdirSync(path.join(ROOT, dir)).sort()) {
|
||||||
|
const text = fs.readFileSync(path.join(ROOT, dir, f), 'utf8');
|
||||||
|
h.update(f + '\n' + (/^signals\./.test(f) ? text : text.split('\n').filter(l => /signal|typesafe|jev/i.test(l)).join('\n')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h.digest('hex');
|
||||||
|
})();
|
||||||
|
function signalsTest(name, fn) {
|
||||||
|
const stamp = path.join(ROOT, 'target/signals-tests',
|
||||||
|
require('node:crypto').createHash('sha256').update(name).digest('hex').slice(0, 16));
|
||||||
|
const want = require('node:crypto').createHash('sha256').update(SIGNALS_CODE + fn.toString()).digest('hex');
|
||||||
|
// Playwright reads the fixtures a test asks for from its parameters; these ask for the page alone.
|
||||||
|
test(name, async ({ page }) => {
|
||||||
|
let had = '';
|
||||||
|
try { had = fs.readFileSync(stamp, 'utf8'); } catch {}
|
||||||
|
test.skip(had === want && !process.env.IPX_SIGNALS_TESTS, 'the Signals code is as it was when this last passed');
|
||||||
|
await fn({ page });
|
||||||
|
fs.mkdirSync(path.dirname(stamp), { recursive: true });
|
||||||
|
fs.writeFileSync(stamp, want);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// The token sets a cookie, so every test starts by presenting it once.
|
// The token sets a cookie, so every test starts by presenting it once.
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto(`/?token=${TOKEN}`);
|
await page.goto(`/?token=${TOKEN}`);
|
||||||
@@ -11,7 +43,8 @@ test('the page loads and lists the configured feeds', async ({ page }) => {
|
|||||||
// Regression: a ReferenceError in the script left the shell rendered and the sidebar
|
// Regression: a ReferenceError in the script left the shell rendered and the sidebar
|
||||||
// empty, with every handler below the error dead. Server-side checks all passed.
|
// empty, with every handler below the error dead. Server-side checks all passed.
|
||||||
// Four top-level feeds in the fixture config; the OPML's children are inside a closed folder.
|
// Four top-level feeds in the fixture config; the OPML's children are inside a closed folder.
|
||||||
await expect(page.locator('.feed')).toHaveCount(5, { timeout: 15_000 });
|
// The Signals folder is above them, and no feed.
|
||||||
|
await expect(page.locator('.feed:not(.sigs)')).toHaveCount(5, { timeout: 15_000 });
|
||||||
await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible();
|
await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible();
|
||||||
const errors = [];
|
const errors = [];
|
||||||
page.on('pageerror', e => errors.push(e.message));
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
@@ -403,7 +436,8 @@ test('an OPML subscription is a collapsible folder', async ({ page }) => {
|
|||||||
// Only a folder has a triangle, and it is a button that says whether the folder is open.
|
// Only a folder has a triangle, and it is a button that says whether the folder is open.
|
||||||
const chev = page.locator('.feed.group .chev');
|
const chev = page.locator('.feed.group .chev');
|
||||||
await expect(chev).toBeVisible({ timeout: 20_000 });
|
await expect(chev).toBeVisible({ timeout: 20_000 });
|
||||||
await expect(page.locator('.feed:not(.group) .chev')).toHaveCount(0);
|
// The Signals folder is a folder too (#255).
|
||||||
|
await expect(page.locator('.feed:not(.group):not(.sigs) .chev')).toHaveCount(0);
|
||||||
await expect(chev).toHaveAttribute('aria-expanded', 'false');
|
await expect(chev).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
|
||||||
// Closed by default: the children are not listed until the folder is opened.
|
// Closed by default: the children are not listed until the folder is opened.
|
||||||
@@ -413,9 +447,12 @@ test('an OPML subscription is a collapsible folder', async ({ page }) => {
|
|||||||
// Scoped to the sidebar: the name also appears as the page heading once selected.
|
// Scoped to the sidebar: the name also appears as the page heading once selected.
|
||||||
await expect(page.locator('#feedlist').getByText('Grouped Show')).toBeVisible();
|
await expect(page.locator('#feedlist').getByText('Grouped Show')).toBeVisible();
|
||||||
|
|
||||||
// The subscription's own page lists what is inside it.
|
// The subscription's own page lists the items of the shows inside it, together (#283).
|
||||||
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
||||||
await expect(page.locator('.childrow')).toHaveCount(2);
|
await page.locator('#content .tabs button[data-f="all"]').click();
|
||||||
|
await expect(page.locator('.ep', { hasText: 'Aardvark Ep' })).toBeVisible();
|
||||||
|
await expect(page.locator('.ep', { hasText: 'Grouped Ep' })).toBeVisible();
|
||||||
|
await expect(page.locator('.childrow')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('inside an OPML, feeds with unread items are listed first', async ({ page }) => {
|
test('inside an OPML, feeds with unread items are listed first', async ({ page }) => {
|
||||||
@@ -448,8 +485,68 @@ test('inside an OPML, feeds with unread items are listed first', async ({ page }
|
|||||||
await page.reload();
|
await page.reload();
|
||||||
const want = ['Grouped Show', 'Aardvark Radio'];
|
const want = ['Grouped Show', 'Aardvark Radio'];
|
||||||
await expect(page.locator('#feedlist .feed.child b')).toHaveText(want, { timeout: 20_000 });
|
await expect(page.locator('#feedlist .feed.child b')).toHaveText(want, { timeout: 20_000 });
|
||||||
|
// The OPML's Unread tab has the unread show's item and not the read one's (#283).
|
||||||
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
||||||
await expect(page.locator('.childrow b')).toHaveText(want);
|
await page.locator('#content .tabs button[data-f="unread"]').click();
|
||||||
|
await expect(page.locator('.ep', { hasText: 'Grouped Ep' })).toBeVisible();
|
||||||
|
await expect(page.locator('.ep', { hasText: 'Aardvark Ep' })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a show inside an OPML can be kept, and only it', async ({ page }) => {
|
||||||
|
await page.evaluate(() =>
|
||||||
|
fetch('/api/fetch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ feed: 'test-subscriptions', force: true }),
|
||||||
|
}));
|
||||||
|
const chev = page.locator('.feed.group .chev');
|
||||||
|
await expect(chev).toBeVisible({ timeout: 20_000 });
|
||||||
|
if (await chev.getAttribute('aria-expanded') === 'false') await chev.click();
|
||||||
|
await page.locator('#feedlist').getByText('Aardvark Radio').click();
|
||||||
|
|
||||||
|
// A plus on the show's page; pressed, a check (#280).
|
||||||
|
const keep = page.locator('#content .acts [data-a="keep"]');
|
||||||
|
await expect(keep).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await expect(keep).toHaveAttribute('title', /Test Subscriptions drops it/);
|
||||||
|
await keep.click();
|
||||||
|
await expect(keep).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
const kept = await page.evaluate(() => fetch('/api/feeds').then((r) => r.json()));
|
||||||
|
expect((kept.feeds || kept).find((f) => f.title === 'Aardvark Radio')?.kept).toBe(true);
|
||||||
|
await keep.click();
|
||||||
|
await expect(keep).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
|
||||||
|
// The OPML itself has no Keep.
|
||||||
|
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
||||||
|
await expect(page.locator('#content h2')).toHaveText('Test Subscriptions');
|
||||||
|
await expect(page.locator('#content [data-a="keep"]')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an item sent as HTML and Markdown switches between them', async ({ page }) => {
|
||||||
|
const api = (p, init) => page.evaluate(([p, init]) => fetch(p, { headers: { 'Content-Type': 'application/json' }, ...init })
|
||||||
|
.then((r) => r.json().catch(() => null)), [p, init]);
|
||||||
|
const added = await api('/api/feeds', { method: 'POST', body: JSON.stringify({ url: 'http://127.0.0.1:8792/markdown.xml' }) });
|
||||||
|
const id = added.id || added.feed?.id || 'markdown-blog';
|
||||||
|
await page.reload();
|
||||||
|
await page.locator('#feedlist .feed', { hasText: 'Markdown Blog' }).click({ timeout: 20_000 });
|
||||||
|
await page.locator('#content .tabs button[data-f="all"]').click();
|
||||||
|
await page.locator('.ep', { hasText: 'Both Ways' }).click();
|
||||||
|
|
||||||
|
// The publisher's HTML first; the choice is between the title and the item's buttons (#285).
|
||||||
|
const view = page.locator('#detail .dmeta .tview');
|
||||||
|
await expect(view).toHaveValue('html');
|
||||||
|
await expect(page.locator('#detail .dbody b')).toHaveText('HTML');
|
||||||
|
await view.selectOption('markdown');
|
||||||
|
await expect(page.locator('#detail .dbody strong')).toHaveText('Markdown');
|
||||||
|
await expect(view.locator('option')).toHaveText(['HTML', 'Markdown']);
|
||||||
|
// Kept for the next item; set back so nothing after this sees it.
|
||||||
|
expect(await page.evaluate(() => localStorage.getItem('ipx.textview'))).toBe('markdown');
|
||||||
|
await view.selectOption('html');
|
||||||
|
|
||||||
|
// An item with no Markdown has no choice to make.
|
||||||
|
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
||||||
|
await page.locator('.ep').first().click();
|
||||||
|
await expect(page.locator('#detail .tview')).toHaveCount(0);
|
||||||
|
await api('/api/feeds/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('marking an OPML subscription read covers the feeds inside it', async ({ page }) => {
|
test('marking an OPML subscription read covers the feeds inside it', async ({ page }) => {
|
||||||
@@ -1141,6 +1238,8 @@ test('one action, one icon: the toolbar, the page and every dialog agree', async
|
|||||||
() => page.locator('#content .acts [data-a="settings"]').click(),
|
() => page.locator('#content .acts [data-a="settings"]').click(),
|
||||||
() => page.locator('#content .acts [data-a="dl"]').click(),
|
() => page.locator('#content .acts [data-a="dl"]').click(),
|
||||||
() => page.locator('#content .acts [data-a="rm"]').click(),
|
() => page.locator('#content .acts [data-a="rm"]').click(),
|
||||||
|
// The Signals folder is not there without a key (#271); the dialog is the same either way.
|
||||||
|
() => page.evaluate(() => signalModal()),
|
||||||
];
|
];
|
||||||
for (const open of dialogs) {
|
for (const open of dialogs) {
|
||||||
await open();
|
await open();
|
||||||
@@ -1668,7 +1767,8 @@ test.describe('an item with no files, on a phone', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('a pinned feed, even one from inside a folder, goes to the top of the list', async ({ page }) => {
|
test('a pinned feed, even one from inside a folder, goes to the top of the list', async ({ page }) => {
|
||||||
const rows = page.locator('#feedlist .feed');
|
// The Signals folder is first, under All Subscriptions (#256), and no feed.
|
||||||
|
const rows = page.locator('#feedlist .feed:not(.sigs)');
|
||||||
const group = page.locator('.feed.group').first();
|
const group = page.locator('.feed.group').first();
|
||||||
if ((await group.locator('.chev').getAttribute('aria-expanded')) !== 'true') await group.locator('.chev').click();
|
if ((await group.locator('.chev').getAttribute('aria-expanded')) !== 'true') await group.locator('.chev').click();
|
||||||
const child = page.locator('.feed.child').first();
|
const child = page.locator('.feed.child').first();
|
||||||
@@ -1851,6 +1951,22 @@ test('a page brought back into view catches up with what was read elsewhere', as
|
|||||||
await expect(badge).toHaveText(String(before - 1));
|
await expect(badge).toHaveText(String(before - 1));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a search box has an x that clears it once something is typed', async ({ page }) => {
|
||||||
|
// #291: inside the box, at its right end, and the list comes back whole.
|
||||||
|
const box = page.locator('#feedFilter');
|
||||||
|
const x = page.locator('.searchwrap .clearx');
|
||||||
|
const all = await page.locator('#feedlist .feed').count();
|
||||||
|
await expect(x).toBeHidden();
|
||||||
|
await box.fill('zzz-nothing');
|
||||||
|
await expect(x).toBeVisible();
|
||||||
|
await expect(page.locator('#feedlist .feed')).toHaveCount(0);
|
||||||
|
await x.click();
|
||||||
|
await expect(box).toHaveValue('');
|
||||||
|
await expect(box).toBeFocused();
|
||||||
|
await expect(x).toBeHidden();
|
||||||
|
await expect(page.locator('#feedlist .feed')).toHaveCount(all);
|
||||||
|
});
|
||||||
|
|
||||||
test('a feed\'s settings say what downloads, take words as chips, and keep what is saved', async ({ page }) => {
|
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.
|
// The redesigned dialog (#167): the download choice, word chips, the fold, and a save that holds.
|
||||||
const open = async () => {
|
const open = async () => {
|
||||||
@@ -2015,3 +2131,163 @@ test('a downloaded file\'s buttons sit on one row in the files pane', async ({ p
|
|||||||
const tops = await box.locator('.btn').evaluateAll(bs => bs.map(b => Math.round(b.getBoundingClientRect().top)));
|
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);
|
expect(new Set(tops).size, 'play, save, share and delete wrapped onto two rows').toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a list seen before shows at once, and one not yet seen shows a spinner while it loads', async ({ page }) => {
|
||||||
|
// #247. Every list's answer held back a second and a half, as a slow connection would.
|
||||||
|
await page.route(/\/api\/(entries|feeds\/[^/]+\/entries)\?/, async route => {
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
await page.evaluate(() => { lists.clear(); fetching.clear(); });
|
||||||
|
await page.locator('.place[data-id=":all"]').click();
|
||||||
|
await expect(page.locator('#eps .listload'), 'waited for, and said so').toBeVisible();
|
||||||
|
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
||||||
|
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 10_000 });
|
||||||
|
// Back to All Subscriptions: drawn from what was kept, before the slow answer could come.
|
||||||
|
await page.locator('.place[data-id=":all"]').click();
|
||||||
|
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 500 });
|
||||||
|
await expect(page.locator('#eps .listload')).toHaveCount(0);
|
||||||
|
await page.unroute(/\/api\/(entries|feeds\/[^/]+\/entries)\?/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Currently Listening and the Directory show at once once seen, with a spinner the first time', async ({ page }) => {
|
||||||
|
// #248. Both answers held back a second and a half.
|
||||||
|
const slow = /\/api\/(directory$|entries\?filter=in_progress)/;
|
||||||
|
await page.route(slow, async route => { await new Promise(r => setTimeout(r, 1500)); await route.continue(); });
|
||||||
|
// What the page fetched ahead when it loaded is let finish and then forgotten, and no more is
|
||||||
|
// fetched ahead, or the first visit would find the Directory already here, as it is meant to.
|
||||||
|
await page.waitForFunction(() => pageFetching.size === 0 && fetching.size === 0);
|
||||||
|
await page.evaluate(() => { window.preloadLists = () => {}; pages.clear(); });
|
||||||
|
for (const [id, drawn] of [[':listening', '#listening .childrow, #listening .hint'], [':directory', '#directory > *:not(.listload)']]) {
|
||||||
|
await page.locator(`.place[data-id="${id}"]`).click();
|
||||||
|
await expect(page.locator('.listload'), `${id}: waited for, and said so`).toBeVisible();
|
||||||
|
await expect(page.locator(drawn).first()).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.locator('.place[data-id=":all"]').click();
|
||||||
|
await page.locator(`.place[data-id="${id}"]`).click();
|
||||||
|
await expect(page.locator(drawn).first(), `${id}: drawn from what was kept`).toBeVisible({ timeout: 500 });
|
||||||
|
await expect(page.locator('.listload')).toHaveCount(0);
|
||||||
|
}
|
||||||
|
await page.unroute(slow);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the page comes compressed, and the compressed copies cannot be asked for by name', async ({ page }) => {
|
||||||
|
// #253: brotli made at build, served in place of the file.
|
||||||
|
const js = await page.request.get('/app.js', { headers: { 'Accept-Encoding': 'br, gzip' } });
|
||||||
|
expect(js.status()).toBe(200);
|
||||||
|
expect(js.headers()['content-encoding']).toBe('br');
|
||||||
|
expect(js.headers()['content-type']).toContain('javascript');
|
||||||
|
const gz = await page.request.get('/app.css', { headers: { 'Accept-Encoding': 'gzip' } });
|
||||||
|
expect(gz.headers()['content-encoding']).toBe('gzip');
|
||||||
|
// The admin page's script, under its compressed name, would skip the check that keeps it to admins.
|
||||||
|
for (const p of ['/admin.js.br', '/admin.html.gz', '/app.js.br']) {
|
||||||
|
expect((await page.request.get(p)).status(), p).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
signalsTest('a Signal holds the items Jev says fit its keywords, read where they are read', async ({ page }) => {
|
||||||
|
// #254. Jev is the fixture server's stand-in; the show is added here and removed at the end.
|
||||||
|
expect((await page.request.patch('/api/me', { data: { typesafe_key: 'test-key' } })).status()).toBe(204);
|
||||||
|
const { id: feed } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/signal.xml' } })).json();
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// The + on the folder's page (#265); making one opens the folder to show it.
|
||||||
|
await page.locator('#feedlist .sigs').click();
|
||||||
|
await page.locator('#content .acts [data-a="new"]').click();
|
||||||
|
await page.locator('#sgname').fill('F1');
|
||||||
|
await page.locator('#sgkwin').fill('monaco');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
// An icon of its own, found by searching Font Awesome Free (#261).
|
||||||
|
await page.locator('#sgicon').click();
|
||||||
|
await page.locator('#sgiconq').fill('flag checkered');
|
||||||
|
await page.locator('[data-ic="solid/flag-checkered"]').click();
|
||||||
|
await page.locator('#sgsave').click();
|
||||||
|
const row = page.locator('#feedlist .feed.child', { hasText: 'F1' });
|
||||||
|
await expect(row).toHaveClass(/sel/);
|
||||||
|
await expect(row.locator('.ficon')).toHaveAttribute('style', /\/icons\/solid\/flag-checkered\.svg/);
|
||||||
|
expect((await page.request.get('/icons/solid/flag-checkered.svg')).status()).toBe(200);
|
||||||
|
|
||||||
|
const signals = async () => (await (await page.request.get('/api/signals')).json());
|
||||||
|
const sg = (await signals()).find(s => s.name === 'F1');
|
||||||
|
const shown = async () => (await (await page.request.get(`/api/signals/${sg.id}/entries`)).json()).entries.map(e => e.title);
|
||||||
|
await expect.poll(shown, { timeout: 15_000 }).toEqual(['Monaco qualifying']);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await row.click();
|
||||||
|
await page.locator('.ep', { hasText: 'Monaco qualifying' }).click();
|
||||||
|
await expect.poll(async () => (await (await page.request.get(`/api/entries/${encodeURIComponent(feed)}/sig-1`)).json()).read,
|
||||||
|
{ timeout: 5_000 }).toBe(true);
|
||||||
|
expect((await signals()).find(s => s.id === sg.id).unread).toBe(0);
|
||||||
|
// And the sidebar says so without a reload: it kept saying 1 (#258).
|
||||||
|
await expect(row.locator('.badge')).toHaveText('0');
|
||||||
|
|
||||||
|
expect((await page.request.get(`/api/signals/${sg.id + 1000}/entries`)).status(), 'no such Signal').toBe(404);
|
||||||
|
|
||||||
|
await page.request.delete(`/api/signals/${sg.id}`);
|
||||||
|
await page.request.delete(`/api/feeds/${encodeURIComponent(feed)}`);
|
||||||
|
await page.request.patch('/api/me', { data: { typesafe_key: null } });
|
||||||
|
});
|
||||||
|
|
||||||
|
signalsTest('a Signal with no key to judge it links to where one is got and where it goes', async ({ page }) => {
|
||||||
|
await page.request.patch('/api/me', { data: { typesafe_key: null } });
|
||||||
|
const { id } = await (await page.request.post('/api/signals', { data: { name: 'Keyless', keywords: ['x'] } })).json();
|
||||||
|
await page.reload();
|
||||||
|
await page.evaluate(id => selectFeed(':signal:' + id), id);
|
||||||
|
await expect(page.locator('#content a[href="https://typesafe.ai"]')).toBeVisible();
|
||||||
|
await page.locator('#content [data-keylink]').click();
|
||||||
|
await expect(page.locator('#stskey')).toBeFocused();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.request.delete(`/api/signals/${id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
signalsTest('Signals are turned on in Settings with a key TypeSafe takes, and off again by forgetting it', async ({ page }) => {
|
||||||
|
// #271. The test daemon has no key of its own, so each person brings theirs.
|
||||||
|
await page.request.patch('/api/me', { data: { typesafe_key: null } });
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('#feedlist .sigs')).toHaveCount(0);
|
||||||
|
await page.evaluate(() => prefsModal());
|
||||||
|
await expect(page.locator('#ssigkey')).toBeHidden();
|
||||||
|
await page.locator('#ssigon').check();
|
||||||
|
await page.locator('#stskey').fill('bad-key');
|
||||||
|
await page.locator('#stskey').press('Enter');
|
||||||
|
await page.locator('#stskey').blur();
|
||||||
|
await expect(page.locator('.toast', { hasText: 'TypeSafe did not accept that key' })).toBeVisible();
|
||||||
|
expect((await (await page.request.get('/api/me')).json()).typesafe_key_set).toBe(false);
|
||||||
|
await page.locator('#stskey').fill('test-key');
|
||||||
|
await page.locator('#stskey').blur();
|
||||||
|
await expect(page.locator('#feedlist .sigs')).toHaveCount(1);
|
||||||
|
page.once('dialog', d => d.accept());
|
||||||
|
await page.locator('#ssigon').uncheck();
|
||||||
|
await expect(page.locator('#feedlist .sigs')).toHaveCount(0);
|
||||||
|
expect((await (await page.request.get('/api/me')).json()).typesafe_key_set).toBe(false);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
});
|
||||||
|
|
||||||
|
signalsTest('the server pays for Signals only when it has a key to pay with', async ({ page }) => {
|
||||||
|
// #271. With the shell's TYPESAFE_KEY (/src/.envrc's development key) the server can pay;
|
||||||
|
// Jev itself is still the fixture server's stand-in.
|
||||||
|
const { typesafe_server_key: has } = await (await page.request.get('/api/settings')).json();
|
||||||
|
await page.request.patch('/api/me', { data: { typesafe_key: null } });
|
||||||
|
expect((await page.request.patch('/api/settings', { data: { signals_on_server: true } })).status()).toBe(204);
|
||||||
|
try {
|
||||||
|
expect((await (await page.request.get('/api/me')).json()).signals).toBe(has ? 'server' : 'own');
|
||||||
|
await page.goto('/admin#server');
|
||||||
|
if (!has) { await expect(page.locator('#gsig')).toBeDisabled(); return; }
|
||||||
|
await expect(page.locator('#gsig')).toBeChecked();
|
||||||
|
|
||||||
|
// No key of your own, and Signals are there, judged on the server's.
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('#feedlist .sigs')).toHaveCount(1);
|
||||||
|
await page.evaluate(() => prefsModal());
|
||||||
|
await expect(page.locator('#stskey')).toHaveCount(0);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
const { id: feed } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/signal.xml' } })).json();
|
||||||
|
const { id } = await (await page.request.post('/api/signals', { data: { name: 'Bread', keywords: ['baking'] } })).json();
|
||||||
|
const shown = async () => (await (await page.request.get(`/api/signals/${id}/entries`)).json()).entries.map(e => e.title);
|
||||||
|
await expect.poll(shown, { timeout: 15_000 }).toEqual(['Baking bread']);
|
||||||
|
await page.request.delete(`/api/signals/${id}`);
|
||||||
|
await page.request.delete(`/api/feeds/${encodeURIComponent(feed)}`);
|
||||||
|
} finally {
|
||||||
|
await page.request.patch('/api/settings', { data: { signals_on_server: false } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
8
tests/ui/fixtures/markdown.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<rss version="2.0" xmlns:source="https://source.scripting.com/">
|
||||||
|
<channel><title>Markdown Blog</title><link>http://127.0.0.1:8792/</link><description>HTML and Markdown both, as Scripting News sends.</description>
|
||||||
|
<item><title>Both Ways</title><guid>md-1</guid>
|
||||||
|
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
|
||||||
|
<description><p>The <b>HTML</b> copy.</p></description>
|
||||||
|
<source:markdown>The **Markdown** copy.</source:markdown></item>
|
||||||
|
</channel></rss>
|
||||||
@@ -7,6 +7,41 @@ const dir = __dirname;
|
|||||||
const port = Number(process.env.FIXTURE_PORT || 8792);
|
const port = Number(process.env.FIXTURE_PORT || 8792);
|
||||||
|
|
||||||
http.createServer((req, res) => {
|
http.createServer((req, res) => {
|
||||||
|
// Jev, for Signals (#254): sure of an item whose text has a question's keyword in it. Refuses
|
||||||
|
// a request in the shape TypeSafe refuses, as it does (#257).
|
||||||
|
if (req.method === 'POST' && req.url === '/jev') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', c => { body += c; });
|
||||||
|
req.on('end', () => {
|
||||||
|
// As TypeSafe answers a key it does not know (#271).
|
||||||
|
if (req.headers.authorization === 'Bearer bad-key') {
|
||||||
|
res.writeHead(401, { 'content-type': 'application/json' }).end('{"detail":"invalid api key"}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { state, questions } = JSON.parse(body);
|
||||||
|
if (Object.values(questions).some(q => typeof q.criteria !== 'object')) {
|
||||||
|
res.writeHead(422, { 'content-type': 'application/json' }).end('{"detail":"criteria should be an object"}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const title = String(state).toLowerCase();
|
||||||
|
const answers = {};
|
||||||
|
for (const [id, q] of Object.entries(questions)) {
|
||||||
|
// A key's check asks about no Signal's words.
|
||||||
|
const words = /described by these words: ([^.]*)\./.exec(q.instructions)?.[1].split(', ') ?? [];
|
||||||
|
answers[id] = { noul: words.some(w => title.includes(w.toLowerCase())) ? 0.9 : 0.1 };
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ answers }));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Dated today, since a Signal looks at the last 30 days only.
|
||||||
|
if (req.url === '/signal.xml') {
|
||||||
|
const now = new Date().toUTCString();
|
||||||
|
const item = (guid, title) => `<item><guid>${guid}</guid><title>${title}</title><pubDate>${now}</pubDate></item>`;
|
||||||
|
res.writeHead(200, { 'content-type': 'application/xml' }).end(`<?xml version="1.0"?><rss version="2.0"><channel>
|
||||||
|
<title>Signal Show</title>${item('sig-1', 'Monaco qualifying')}${item('sig-2', 'Baking bread')}</channel></rss>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const name = decodeURIComponent(req.url.split('?')[0].replace(/^\//, '')) || 'index';
|
const name = decodeURIComponent(req.url.split('?')[0].replace(/^\//, '')) || 'index';
|
||||||
const file = path.join(dir, path.basename(name));
|
const file = path.join(dir, path.basename(name));
|
||||||
fs.readFile(file, (err, body) => {
|
fs.readFile(file, (err, body) => {
|
||||||
|
|||||||
@@ -11,6 +11,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-10-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A search box has an x at its right end once something is typed in it, which clears it: items, the feed list, the log in Admin and a Signal's icons (#291).
|
||||||
|
- A feed's, an OPML subscription's or a Patreon creator's settings have In All Subscriptions: off keeps its items out of All Subscriptions and its unread count, still there on its own page, in Signals and in Currently Listening (#288).
|
||||||
|
- A feed's settings have a Signals switch beside All Subscriptions, to keep its items out of every Signal (#289).
|
||||||
|
- An item sent as both HTML and Markdown, as Scripting News's are, has a choice beside its buttons to show it as the publisher's HTML or as the Markdown, kept from one item to the next (#285).
|
||||||
|
- A show inside an OPML subscription has a Keep button, a plus, on its page, so it stays on your list as a show of its own if the OPML drops it or you unsubscribe from the OPML (#280).
|
||||||
|
- Admin, Server: a setting to give everyone Signals on the server's TypeSafe key (#271).
|
||||||
|
- Admin, Server: how many days back a new Signal reads (#276).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- A feed's settings are in parts, each named beside its controls: Address, Downloads, Playback, Where it shows and Directory, with All Subscriptions and Signals drawn as the sidebar draws them, and DeadAir's pause as a slider without a heading (#290).
|
||||||
|
- An OPML subscription's page lists the items of all its shows together, newest first, with the same tabs, search and sort as a show's, in place of a list of the shows; the shows are still in the folder in the feed list (#283).
|
||||||
|
- Unless the server pays for them, Signals are turned on in Settings with your own TypeSafe key, and the Signals folder shows only once they are on (#271).
|
||||||
|
- A Signal's dialog says how far back it reads, and that an edit reads again only what was picked out (#276).
|
||||||
|
- Each request's span in Tempo is named for its method and path, such as `GET /api/entries`, instead of `ipx-web` for every one (#286).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Another person's download, its progress and its failure, no longer shows on your page (#294).
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-10-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Signals pick what matters out of the noise of every feed you subscribe to: New signal takes a name, the words to listen for and, if those are not enough, a sentence saying what you mean, and its page lists the items from your feeds that are about it, read and unread as in their own feeds. Settings takes your TypeSafe key, which Signals need, and a Signal without one links to typesafe.ai to get one and to the key's place in Settings (#254, #266). They are kept in a Signals folder first in the feed list, under All Subscriptions, closed or open as an OPML is, with its own page listing them and a + there for a new one (#255, #256, #265). Each can have an icon of its own, searched for among all of Font Awesome Free, in place of its initials (#261), or one picked for it with Auto, which a Signal made without an icon gets by itself; each Signal's row counts its items and unread as a feed's does (#263).
|
||||||
|
- A list opens at once when you have seen it before or the page fetched it ahead: All Subscriptions and the feeds with something unread are fetched when the page loads, and a feed or filter as the pointer or a finger reaches it, then refreshed behind once shown. A list that has to be waited for shows a spinner instead of nothing (#247). Currently Listening and the Directory's front page are fetched ahead the same way and open at once (#248).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The page reports how long each list you open takes to show, from the click to the list drawn, for the dashboard (#246).
|
||||||
|
- The page's files are compressed once when built, brotli at its strongest and gzip, and sent in place of the files: the script 30 KB where it was 37, the stylesheet 11 where it was 14. Traces go to the collector compressed (#253).
|
||||||
|
- The page and the API's answers are sent compressed, zstd or gzip, which the feed list and item lists, over 100 KB each, were not; live updates and audio are not compressed (#239).
|
||||||
|
|
||||||
## [1.0.2] - 2026-10-15
|
## [1.0.2] - 2026-10-15
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -49,7 +86,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- On a phone, the Subscribe button on a Directory feed's page is full size again, not squashed to a sliver (#212).
|
- 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.2...main
|
[unreleased]: https://git.sdf1.net/rays/ipx/compare/web-v1.2.0...main
|
||||||
|
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/web-v1.1.0...web-v1.2.0
|
||||||
|
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.2...web-v1.1.0
|
||||||
[1.0.2]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.1...web-v1.0.2
|
[1.0.2]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.1...web-v1.0.2
|
||||||
[1.0.1]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.0...web-v1.0.1
|
[1.0.1]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.0...web-v1.0.1
|
||||||
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/web-v1.0.0
|
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/web-v1.0.0
|
||||||
|
|||||||
@@ -30,7 +30,9 @@
|
|||||||
|
|
||||||
(tracing-on) {
|
(tracing-on) {
|
||||||
tracing {
|
tracing {
|
||||||
span ipx-web
|
# Named for the request, as ipx names its own (#286): one name for all left every span in
|
||||||
|
# Tempo looking the same.
|
||||||
|
span "{http.request.method} {http.request.uri.path}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(tracing-off) {
|
(tracing-off) {
|
||||||
@@ -57,6 +59,21 @@ http://{$WEB_BIND::8080} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Compressed, zstd or gzip as the browser takes them: the feed list and a page of items went
|
||||||
|
# out at over 100 KB each (#239). Not the live updates, which a compressor would hold back
|
||||||
|
# until it had enough to send, nor /media, which is audio and video already compressed.
|
||||||
|
@compress not path /api/events /media/*
|
||||||
|
encode @compress zstd gzip
|
||||||
|
|
||||||
|
# The copies web/build.mjs compressed are served in place of their file, never asked for by
|
||||||
|
# name: /admin.js.br would be the admin page's script without the check below that keeps it to
|
||||||
|
# admins (#253).
|
||||||
|
# A handle of its own: a bare respond runs after the handles, and the last one serves any file.
|
||||||
|
@packed path *.br *.gz
|
||||||
|
handle @packed {
|
||||||
|
respond 404
|
||||||
|
}
|
||||||
|
|
||||||
# Before anything else, so no page or API call sees them: only ipx sets these.
|
# 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}
|
@forged not remote_ip {$TUNNEL_PEER:255.255.255.255}
|
||||||
request_header @forged -Cf-Access-Authenticated-User-Email
|
request_header @forged -Cf-Access-Authenticated-User-Email
|
||||||
@@ -91,7 +108,9 @@ http://{$WEB_BIND::8080} {
|
|||||||
rewrite / /index.html
|
rewrite / /index.html
|
||||||
rewrite /admin /admin.html
|
rewrite /admin /admin.html
|
||||||
header Cache-Control no-cache
|
header Cache-Control no-cache
|
||||||
file_server
|
file_server {
|
||||||
|
precompressed br gzip
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# The sign-in form, except for someone Cloudflare Access vouches for, who is sent to the app.
|
# The sign-in form, except for someone Cloudflare Access vouches for, who is sent to the app.
|
||||||
@@ -102,18 +121,22 @@ http://{$WEB_BIND::8080} {
|
|||||||
}
|
}
|
||||||
rewrite * /login.html
|
rewrite * /login.html
|
||||||
header Cache-Control no-cache
|
header Cache-Control no-cache
|
||||||
file_server
|
file_server {
|
||||||
|
precompressed br gzip
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Scripts and the stylesheet are named by a hash of their contents (web/build.mjs), so a browser
|
# 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.
|
# keeps them a year; the icons a day, under names that do not change.
|
||||||
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js
|
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js /icons/index.json
|
||||||
header @hashed Cache-Control "public, max-age=31536000, immutable"
|
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.
|
# The same PNG as /favicon.png, for a browser that asks for /favicon.ico on its own.
|
||||||
header /favicon.ico Content-Type image/png
|
header /favicon.ico Content-Type image/png
|
||||||
@icons path *.png *.svg *.ico *.woff2
|
@icons path *.png *.svg *.ico *.woff2
|
||||||
header @icons Cache-Control "public, max-age=86400"
|
header @icons Cache-Control "public, max-age=86400"
|
||||||
handle {
|
handle {
|
||||||
file_server
|
file_server {
|
||||||
|
precompressed br gzip
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
89
web/app.css
@@ -501,9 +501,9 @@ a{color:var(--accent)}
|
|||||||
}
|
}
|
||||||
#applogo{display:flex;flex:none}
|
#applogo{display:flex;flex:none}
|
||||||
#applogo img{width:28px;height:28px;border-radius:7px}
|
#applogo img{width:28px;height:28px;border-radius:7px}
|
||||||
/* The logo in the mode's own variant. Dark unless the page says light, as the palette is: with
|
/* The logo, and the app-icon tiles, in the mode's own variant. Dark unless the page says light, as the palette is: with
|
||||||
Auto, data-mode arrives with the script, and until then the page is drawn dark. */
|
Auto, data-mode arrives with the script, and until then the page is drawn dark. */
|
||||||
:root[data-mode="light"] .logo.ondark,:root:not([data-mode="light"]) .logo.onlight{display:none}
|
:root[data-mode="light"] .ondark,:root:not([data-mode="light"]) .onlight{display:none}
|
||||||
/* Classic is the 2004 app, so it wears the 2004 app's icon. */
|
/* Classic is the 2004 app, so it wears the 2004 app's icon. */
|
||||||
.logo.classic,:root[data-theme="classic"] .logo:not(.classic){display:none}
|
.logo.classic,:root[data-theme="classic"] .logo:not(.classic){display:none}
|
||||||
:root[data-theme="classic"] .logo.classic{display:block}
|
:root[data-theme="classic"] .logo.classic{display:block}
|
||||||
@@ -537,7 +537,7 @@ a{color:var(--accent)}
|
|||||||
position:sticky;top:0;
|
position:sticky;top:0;
|
||||||
}
|
}
|
||||||
#topbar .grow{flex:1}
|
#topbar .grow{flex:1}
|
||||||
#topbar #epSearch{width:260px;flex:none}
|
#topbar #epBox{width:260px;flex:none}
|
||||||
.tgroup{display:flex;flex:none;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel2)}
|
.tgroup{display:flex;flex:none;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel2)}
|
||||||
.tgroup button,.tgroup a{padding:5px 11px;min-width:34px;color:var(--dim);font-size:14px;line-height:1.3}
|
.tgroup button,.tgroup a{padding:5px 11px;min-width:34px;color:var(--dim);font-size:14px;line-height:1.3}
|
||||||
.tgroup a{display:inline-grid;place-items:center}
|
.tgroup a{display:inline-grid;place-items:center}
|
||||||
@@ -567,6 +567,17 @@ input[type=search],input[type=text],input[type=password],input[type=number],sele
|
|||||||
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
|
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
|
||||||
}
|
}
|
||||||
input:focus,select:focus{outline:0;border-color:var(--accent)}
|
input:focus,select:focus{outline:0;border-color:var(--accent)}
|
||||||
|
/* A search box and the x that clears it, inside its right end, there only once something is typed
|
||||||
|
(#291). The browser's own x, in some browsers and not others, gives way to it. */
|
||||||
|
.clearable{position:relative;display:flex;align-items:center;min-width:0}
|
||||||
|
/* With [type=search], or the phone's padding for every text box, later on, takes the room back. */
|
||||||
|
.clearable>input[type=search]{flex:1;min-width:0;padding-right:32px}
|
||||||
|
.clearable>input::-webkit-search-cancel-button{-webkit-appearance:none;appearance:none}
|
||||||
|
.clearx{position:absolute;right:4px;top:50%;transform:translateY(-50%);display:grid;place-items:center;width:26px;height:26px;padding:0;border-radius:50%;color:var(--faint)}
|
||||||
|
.clearx:hover{color:var(--fg)}
|
||||||
|
.clearx svg{width:11px;height:11px}
|
||||||
|
.clearable:has(>input:placeholder-shown) .clearx{display:none}
|
||||||
|
.inline .clearable{flex:1}
|
||||||
/* The wider left gutter is the folder triangle's; everything in the list shifts with it, so the
|
/* The wider left gutter is the folder triangle's; everything in the list shifts with it, so the
|
||||||
feeds still line up with the places above. */
|
feeds still line up with the places above. */
|
||||||
#feedlist{overflow-y:auto;padding:0 8px 12px 16px;flex:1;min-height:0}
|
#feedlist{overflow-y:auto;padding:0 8px 12px 16px;flex:1;min-height:0}
|
||||||
@@ -590,6 +601,26 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
.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}
|
||||||
|
/* A Signal's own icon (#261): Font Awesome's file as a mask, so it is drawn in the text's colour
|
||||||
|
and follows the theme as the page's own icons do. */
|
||||||
|
.ficon{
|
||||||
|
display:block;width:50%;height:50%;background:currentColor;
|
||||||
|
-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat;
|
||||||
|
}
|
||||||
|
.iconpick{flex:none;border-radius:9px}
|
||||||
|
.iconpick .art{width:38px;height:38px;font-size:13px}
|
||||||
|
.icongrid{
|
||||||
|
display:grid;grid-template-columns:repeat(auto-fill,minmax(40px,1fr));gap:4px;
|
||||||
|
max-height:220px;overflow-y:auto;margin-top:8px;
|
||||||
|
}
|
||||||
|
.iconcell{height:40px;border-radius:8px;display:grid;place-items:center;color:var(--dim)}
|
||||||
|
.iconcell:hover,.iconcell[aria-selected="true"]{background:var(--raise);color:var(--fg)}
|
||||||
|
.iconcell .ficon{width:20px;height:20px}
|
||||||
|
/* Auto in the icon picker, while an icon is picked: the scan spinner, beside the word. */
|
||||||
|
.dlink.busy::after{
|
||||||
|
content:"";display:inline-block;width:10px;height:10px;margin-left:6px;vertical-align:-1px;border-radius:50%;
|
||||||
|
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite;
|
||||||
|
}
|
||||||
/* Only a folder has a triangle, hung in the margin so every feed's art lines up with the places
|
/* Only a folder has a triangle, hung in the margin so every feed's art lines up with the places
|
||||||
above it. The button is the row's full height and 24 px wide: a near miss used to open the
|
above it. The button is the row's full height and 24 px wide: a near miss used to open the
|
||||||
folder's page, and 16 px left the triangle cramped against the art. */
|
folder's page, and 16 px left the triangle cramped against the art. */
|
||||||
@@ -756,6 +787,8 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
|||||||
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
|
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
|
||||||
display:grid;place-items:center;color:var(--faint);font-weight:600;overflow:hidden;
|
display:grid;place-items:center;color:var(--faint);font-weight:600;overflow:hidden;
|
||||||
}
|
}
|
||||||
|
.art>.tile{width:100%;height:100%;display:block}
|
||||||
|
#pdead .tile{width:26px;height:26px;border-radius:6px}
|
||||||
/* Initials, tinted by a hash of the name so two "TS" in a row can be told apart. Mixed into the
|
/* Initials, tinted by a hash of the name so two "TS" in a row can be told apart. Mixed into the
|
||||||
page ground, not --raise: that is also the selected row, and a selected tile vanished into it. */
|
page ground, not --raise: that is also the selected row, and a selected tile vanished into it. */
|
||||||
.art.ini{background:color-mix(in srgb,var(--tint) 24%,var(--bg));color:var(--tint)}
|
.art.ini{background:color-mix(in srgb,var(--tint) 24%,var(--bg));color:var(--tint)}
|
||||||
@@ -797,6 +830,10 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
|||||||
#pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden;
|
#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. */
|
/* What a pull started, for a couple of seconds after letting go, under the top bar. */
|
||||||
|
/* A list being waited for (#247): the pull-to-refresh spinner, in the list's place. */
|
||||||
|
.listload{display:flex;align-items:center;justify-content:center;gap:8px;padding:28px 8px;color:var(--dim);font-size:13px}
|
||||||
|
.listload::before{content:"";width:12px;height:12px;border-radius:50%;
|
||||||
|
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite}
|
||||||
#pullspin{position:fixed;left:50%;top:calc(58px + var(--safe-t));transform:translateX(-50%);z-index:55;
|
#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;
|
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)}
|
color:var(--dim);background:var(--panel);border:1px solid var(--line);box-shadow:var(--shadow)}
|
||||||
@@ -822,6 +859,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
|||||||
/* The reader's buttons go as one: as items of the line itself, a long feed name took them onto
|
/* 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). */
|
the next line one at a time (#146). */
|
||||||
.dacts{display:flex;gap:inherit;flex:none}
|
.dacts{display:flex;gap:inherit;flex:none}
|
||||||
|
.dmeta .tview{width:auto;padding:3px 6px;font-size:12px}
|
||||||
#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}
|
||||||
@@ -1068,14 +1106,30 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
/* ---------- a feed's settings (#167) ---------- */
|
/* ---------- 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
|
/* A frame of its own: the body scrolls and Save stays at the foot, where on a phone it had been
|
||||||
three screens down. */
|
three screens down. */
|
||||||
.card.fset{padding:0;display:flex;flex-direction:column;overflow:hidden;width:min(520px,100%)}
|
.card.fset{padding:0;display:flex;flex-direction:column;overflow:hidden;width:min(580px,100%);container-type:inline-size}
|
||||||
.fsetbody{overflow:auto;min-height:0;padding:22px 24px 24px}
|
.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)}
|
.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{display:flex;gap:14px;align-items:center;margin-bottom:20px}
|
||||||
.fsethead .art{width:56px;height:56px;border-radius:12px;font-size: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 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)}
|
.fsethead p{margin:3px 0 0;font-size:12.5px;line-height:1.45;color:var(--dim)}
|
||||||
.faddr{margin-bottom:18px}
|
/* A part of the settings, named in a column of its own beside its controls (#290); on a phone the
|
||||||
|
name sits above them. Ruled apart rather than boxed: the boxes, one round downloads and none
|
||||||
|
round the rest, read as a difference between the settings that was not there. */
|
||||||
|
.fsec{display:grid;grid-template-columns:104px minmax(0,1fr);gap:6px 20px;padding:14px 0 16px;border-top:1px solid var(--line)}
|
||||||
|
.fsec h4{margin:0;padding-top:10px;font-size:13px;font-weight:600;line-height:1.3;color:var(--dim)}
|
||||||
|
.fsecin{display:grid;gap:6px;min-width:0}
|
||||||
|
@container (max-width:480px){
|
||||||
|
.fsec{grid-template-columns:minmax(0,1fr);gap:4px}
|
||||||
|
.fsec h4{padding-top:0}
|
||||||
|
}
|
||||||
|
/* All Subscriptions and Signals with the icons the sidebar gives them, so the switch reads as that place. */
|
||||||
|
.switch.fplace>span:first-child{display:flex;align-items:center;gap:10px}
|
||||||
|
.fico{display:inline-flex;justify-content:center;width:18px;flex:none;color:var(--accent)}
|
||||||
|
.fico svg{width:15px;height:15px}
|
||||||
|
.fsig img{display:block;width:18px;height:18px;border-radius:5px}
|
||||||
|
/* Set on the OPML it came from, not here: shown, and plainly out of reach. */
|
||||||
|
.fplace:has(input:disabled){opacity:.5;cursor:default}
|
||||||
.moved{margin:2px 0 0;font-size:13px;color:var(--accent)}
|
.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,
|
/* 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. */
|
unseen over the track, so a click, a tap, the keyboard and a screen reader all reach it. */
|
||||||
@@ -1090,16 +1144,14 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
.sw input:checked+i{background:var(--accent);border-color:var(--accent)}
|
.sw input:checked+i{background:var(--accent);border-color:var(--accent)}
|
||||||
.sw input:checked+i::after{transform:translateX(16px)}
|
.sw input:checked+i::after{transform:translateX(16px)}
|
||||||
.sw input:focus-visible+i{outline:2px solid var(--accent);outline-offset:2px}
|
.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
|
/* What only matters while it downloads on its own folds away when it is off: the rows in a grid of
|
||||||
when it is off. In --line, not --edge, so the box shows on a phone, which draws no outlines. The
|
one row, 1fr open and 0fr shut, so the fold has a height to move. */
|
||||||
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. */
|
/* 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}
|
.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{display:grid;grid-template-rows:1fr;transition:grid-template-rows .22s ease}
|
||||||
.fdeps.off{grid-template-rows:0fr}
|
.fdeps.off{grid-template-rows:0fr}
|
||||||
.fdeps>div{min-height:0;overflow:hidden}
|
.fdeps>div{min-height:0;overflow:hidden}
|
||||||
.fdepsin{padding:6px 2px 10px}
|
.fdepsin{padding:6px 0 2px}
|
||||||
/* The app's own segmented control, with radio buttons in it, for the arrow keys. */
|
/* The app's own segmented control, with radio buttons in it, for the arrow keys. */
|
||||||
.seg{margin-bottom:12px}
|
.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 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}
|
||||||
@@ -1119,9 +1171,12 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
.wcap{margin:0 0 16px;font-size:13px;line-height:1.55;color:var(--dim)}
|
.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)}
|
.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}
|
.ffield{display:grid;gap:6px;margin-bottom:14px}
|
||||||
|
.fsecin>.ffield:last-child{margin-bottom:0}
|
||||||
|
.fsecin>.cap+.ffield{margin-top:10px}
|
||||||
/* DeadAir's pause: four stops on one scale, a dot at each so it reads as four places to stop. */
|
/* 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}
|
.dpause{display:grid;gap:6px;margin-top:6px}
|
||||||
.dphead{display:flex;justify-content:space-between;align-items:baseline}
|
/* The pause it leaves, over the slider's right end; the slider is named for a screen reader alone. */
|
||||||
|
.dphead{display:flex;justify-content:flex-end;align-items:baseline}
|
||||||
.dphead output{font-variant-numeric:tabular-nums;font-weight:600}
|
.dphead output{font-variant-numeric:tabular-nums;font-weight:600}
|
||||||
.dpslide{--t:22px;position:relative;height:var(--t)}
|
.dpslide{--t:22px;position:relative;height:var(--t)}
|
||||||
.dpslide::before,.dpslide::after{content:'';position:absolute;top:50%;pointer-events:none}
|
.dpslide::before,.dpslide::after{content:'';position:absolute;top:50%;pointer-events:none}
|
||||||
@@ -1138,9 +1193,6 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
.dpends{display:flex;justify-content:space-between;font-size:12.5px;color:var(--dim)}
|
.dpends{display:flex;justify-content:space-between;font-size:12.5px;color:var(--dim)}
|
||||||
.dpause:has(input:disabled){opacity:.45}
|
.dpause:has(input:disabled){opacity:.45}
|
||||||
.dpslide input[type=range]:disabled{cursor:default}
|
.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}
|
.flabel{font-size:14px;font-weight:500}
|
||||||
.cap{margin:0;font-size:12.5px;line-height:1.45;color:var(--dim)}
|
.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. */
|
/* Words and phrases as chips: in one comma-separated box, one ran into the next. */
|
||||||
@@ -1210,7 +1262,7 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
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));
|
||||||
}
|
}
|
||||||
#topbar .grow,.tgroup.item{display:none}
|
#topbar .grow,.tgroup.item{display:none}
|
||||||
#topbar #epSearch{width:auto;flex:1;min-width:0}
|
#topbar #epBox{width:auto;flex:1;min-width:0}
|
||||||
.tgroup button,.tgroup a{padding:4px 8px;min-width:32px}
|
.tgroup button,.tgroup a{padding:4px 8px;min-width:32px}
|
||||||
.fhead.slim .acts{flex:1 1 100%}
|
.fhead.slim .acts{flex:1 1 100%}
|
||||||
.iconbtn{width:38px;height:38px}
|
.iconbtn{width:38px;height:38px}
|
||||||
@@ -1318,7 +1370,7 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
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{
|
||||||
font-size:17px;background:var(--panel2);border-color:var(--edge);border-radius:10px;padding:9px 12px;
|
font-size:17px;background:var(--panel2);border-color:var(--edge);border-radius:10px;padding:9px 12px;
|
||||||
}
|
}
|
||||||
#topbar #epSearch{font-size:16px;border-radius:999px;padding:9px 14px}
|
#topbar #epSearch{font-size:16px;border-radius:999px;padding:9px 34px 9px 14px}
|
||||||
input:focus,select:focus{border-color:var(--accent)}
|
input:focus,select:focus{border-color:var(--accent)}
|
||||||
|
|
||||||
/* The feed's name as the page's large title, the way Podcasts heads a show. */
|
/* The feed's name as the page's large title, the way Podcasts heads a show. */
|
||||||
@@ -1371,6 +1423,7 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
.fsetbody{padding:20px 16px 24px}
|
.fsetbody{padding:20px 16px 24px}
|
||||||
.card.fset .fsetfoot{padding:12px 16px calc(12px + var(--safe-b))}
|
.card.fset .fsetfoot{padding:12px 16px calc(12px + var(--safe-b))}
|
||||||
.fsethead .art{width:64px;height:64px;border-radius:14px}
|
.fsethead .art{width:64px;height:64px;border-radius:14px}
|
||||||
|
.fsec h4{font-size:14px}
|
||||||
.fsethead p,.wcap,.cap{font-size:14px}
|
.fsethead p,.wcap,.cap{font-size:14px}
|
||||||
.switch,.flabel{font-size:17px}
|
.switch,.flabel{font-size:17px}
|
||||||
.switch{min-height:44px}
|
.switch{min-height:44px}
|
||||||
@@ -1386,7 +1439,7 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
.chips{background:var(--panel2);border-color:var(--edge);border-radius:10px}
|
.chips{background:var(--panel2);border-color:var(--edge);border-radius:10px}
|
||||||
.chip{background:var(--panel);font-size:15px;padding:5px 4px 5px 12px}
|
.chip{background:var(--panel);font-size:15px;padding:5px 4px 5px 12px}
|
||||||
.chipx{width:26px;height:26px}
|
.chipx{width:26px;height:26px}
|
||||||
.fdl,.field.boxed{border-radius:14px}
|
.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%)}}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import zlib from 'node:zlib';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import swc from '@swc/core';
|
import swc from '@swc/core';
|
||||||
import html from '@swc/html';
|
import html from '@swc/html';
|
||||||
@@ -23,7 +24,7 @@ const WEB_VERSION = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.js
|
|||||||
// The files are one script, concatenated in this order, not modules: they share one top-level
|
// 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.
|
||||||
const PAGES = {
|
const PAGES = {
|
||||||
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events', 'native'] },
|
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'signals', 'gestures', 'events', 'native'] },
|
||||||
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
||||||
'login.html': { script: 'login.js', src: ['login'] },
|
'login.html': { script: 'login.js', src: ['login'] },
|
||||||
};
|
};
|
||||||
@@ -37,6 +38,23 @@ const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 1
|
|||||||
const HLS = fs.readFileSync(path.join(here, '..', 'node_modules', 'hls.js', 'dist', 'hls.light.min.js'), 'utf8');
|
const HLS = fs.readFileSync(path.join(here, '..', 'node_modules', 'hls.js', 'dist', 'hls.light.min.js'), 'utf8');
|
||||||
const HLS_URL = `/hls.js?v=${hash(HLS)}`;
|
const HLS_URL = `/hls.js?v=${hash(HLS)}`;
|
||||||
|
|
||||||
|
// Font Awesome Free, the set the page's own icons come from, whole, for a Signal's icon (#261):
|
||||||
|
// each icon a file of its own, /icons/<style>/<name>.svg, drawn as a mask so it takes the theme's
|
||||||
|
// colour, and an index of names and search terms the picker fetches when it opens. The page's
|
||||||
|
// script is given the index's address, named by its hash, in place of __ICONS__.
|
||||||
|
const FA = path.join(here, '..', 'node_modules', '@fortawesome', 'fontawesome-free');
|
||||||
|
const FA_STYLES = ['solid', 'regular', 'brands'];
|
||||||
|
/// [`style/name`, label, search terms] for every icon, solid first.
|
||||||
|
export function iconIndex() {
|
||||||
|
const meta = JSON.parse(fs.readFileSync(path.join(FA, 'metadata', 'icon-families.json'), 'utf8'));
|
||||||
|
return FA_STYLES.flatMap(style => fs.readdirSync(path.join(FA, 'svgs', style)).sort().map(f => {
|
||||||
|
const name = f.slice(0, -4), m = meta[name] || {};
|
||||||
|
return [`${style}/${name}`, m.label || name, (m.search?.terms || []).join(' ')];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const ICONS = JSON.stringify(iconIndex());
|
||||||
|
const ICONS_URL = `/icons/index.json?v=${hash(ICONS)}`;
|
||||||
|
|
||||||
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
|
/// 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 } = {}) {
|
||||||
@@ -60,6 +78,7 @@ 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('__HLS_JS__', HLS_URL)
|
||||||
|
.replaceAll('__ICONS__', ICONS_URL)
|
||||||
.replaceAll('__WEB_VERSION__', WEB_VERSION);
|
.replaceAll('__WEB_VERSION__', WEB_VERSION);
|
||||||
const js = swc.transformSync(src, {
|
const js = swc.transformSync(src, {
|
||||||
filename: name + '.ts',
|
filename: name + '.ts',
|
||||||
@@ -104,13 +123,29 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|||||||
// nothing here shows any more but the classic theme.
|
// nothing here shows any more but the classic theme.
|
||||||
for (const [from, to] of [
|
for (const [from, to] of [
|
||||||
['logo.svg', 'logo.svg'], ['logo-dark.svg', 'logo-dark.svg'],
|
['logo.svg', 'logo.svg'], ['logo-dark.svg', 'logo-dark.svg'],
|
||||||
|
...['signals', 'deadair-on', 'deadair-off'].flatMap(n => [`${n}.svg`, `${n}-dark.svg`]).map(f => [f, f]),
|
||||||
['favicon.png', 'favicon.png'], ['favicon.png', 'favicon.ico'], ['favicon-dark.png', 'favicon-dark.png'],
|
['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'],
|
['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'],
|
['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));
|
]) fs.copyFileSync(path.join(here, from), path.join(out, to));
|
||||||
|
for (const style of FA_STYLES) {
|
||||||
|
fs.mkdirSync(path.join(out, 'icons', style), { recursive: true });
|
||||||
|
for (const f of fs.readdirSync(path.join(FA, 'svgs', style))) fs.copyFileSync(path.join(FA, 'svgs', style, f), path.join(out, 'icons', style, f));
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(out, 'icons', 'index.json'), ICONS);
|
||||||
|
fs.copyFileSync(path.join(FA, 'LICENSE.txt'), path.join(out, 'icons', 'LICENSE.txt'));
|
||||||
for (const name of Object.keys(PAGES)) {
|
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);
|
||||||
fs.writeFileSync(path.join(out, script), js);
|
fs.writeFileSync(path.join(out, script), js);
|
||||||
}
|
}
|
||||||
|
// Each text file compressed once here, as hard as it goes, brotli and gzip beside it, for Caddy
|
||||||
|
// to send in its place (#253): smaller than compressing each answer as it goes, and no work then.
|
||||||
|
for (const f of fs.readdirSync(out).filter(f => /\.(html|js|css|svg|txt)$/.test(f))) {
|
||||||
|
const body = fs.readFileSync(path.join(out, f));
|
||||||
|
fs.writeFileSync(path.join(out, f + '.br'), zlib.brotliCompressSync(body, {
|
||||||
|
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: body.length },
|
||||||
|
}));
|
||||||
|
fs.writeFileSync(path.join(out, f + '.gz'), zlib.gzipSync(body, { level: 9 }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
web/deadair-off-dark.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<!-- deadair-off.svg's dark appearance: the same layers in the same places, only recoloured. -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1d4468"/>
|
||||||
|
<stop offset="1" stop-color="#0c2238"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||||
|
<g id="waveform" fill="#8fc2ea">
|
||||||
|
<rect x="120" y="336" width="112" height="352" rx="56" opacity=".3"/>
|
||||||
|
<rect x="264" y="208" width="112" height="608" rx="56" opacity=".5"/>
|
||||||
|
<rect x="648" y="208" width="112" height="608" rx="56" opacity=".5"/>
|
||||||
|
<rect x="792" y="336" width="112" height="352" rx="56" opacity=".3"/>
|
||||||
|
</g>
|
||||||
|
<g id="silence"><rect x="424" y="464" width="176" height="96" rx="48" fill="#ff6a1a"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 868 B |
18
web/deadair-off.svg
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<!-- DeadAir off: deadair-on.svg's waveform with the silence still in its middle, a flat line,
|
||||||
|
its bars muted, as the page's off glyph is only outlined; the silence keeps the orange. -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#dff0fb"/>
|
||||||
|
<stop offset="1" stop-color="#9fccef"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||||
|
<g id="waveform" fill="#2f6aa0">
|
||||||
|
<rect x="120" y="336" width="112" height="352" rx="56" opacity=".3"/>
|
||||||
|
<rect x="264" y="208" width="112" height="608" rx="56" opacity=".5"/>
|
||||||
|
<rect x="648" y="208" width="112" height="608" rx="56" opacity=".5"/>
|
||||||
|
<rect x="792" y="336" width="112" height="352" rx="56" opacity=".3"/>
|
||||||
|
</g>
|
||||||
|
<g id="silence"><rect x="424" y="464" width="176" height="96" rx="48" fill="#ff5500"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 965 B |
17
web/deadair-on-dark.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<!-- deadair-on.svg's dark appearance: the same layers in the same places, only recoloured. -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1d4468"/>
|
||||||
|
<stop offset="1" stop-color="#0c2238"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||||
|
<g id="waveform" fill="#8fc2ea">
|
||||||
|
<rect x="188" y="336" width="112" height="352" rx="56" opacity=".55"/>
|
||||||
|
<rect x="332" y="208" width="112" height="608" rx="56" opacity=".85"/>
|
||||||
|
<rect x="580" y="208" width="112" height="608" rx="56" opacity=".85"/>
|
||||||
|
<rect x="724" y="336" width="112" height="352" rx="56" opacity=".55"/>
|
||||||
|
</g>
|
||||||
|
<g id="needle"><rect x="476" y="160" width="72" height="704" rx="36" fill="#ff6a1a"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 870 B |
20
web/deadair-on.svg
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<!-- DeadAir on, drawn as logo.svg is (square, opaque, one background and flat hard-edged
|
||||||
|
layers, nothing thin enough to vanish at 32px) from the page's own DeadAir glyph: the
|
||||||
|
waveform closed up where its silence was, the cut marked by the needle in orange.
|
||||||
|
deadair-off.svg is the same waveform with the silence still in it. Each <g> is a layer. -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#dff0fb"/>
|
||||||
|
<stop offset="1" stop-color="#9fccef"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||||
|
<g id="waveform" fill="#2f6aa0">
|
||||||
|
<rect x="188" y="336" width="112" height="352" rx="56" opacity=".55"/>
|
||||||
|
<rect x="332" y="208" width="112" height="608" rx="56" opacity=".85"/>
|
||||||
|
<rect x="580" y="208" width="112" height="608" rx="56" opacity=".85"/>
|
||||||
|
<rect x="724" y="336" width="112" height="352" rx="56" opacity=".55"/>
|
||||||
|
</g>
|
||||||
|
<g id="needle"><rect x="476" y="160" width="72" height="704" rx="36" fill="#ff5500"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -28,7 +28,7 @@
|
|||||||
<button id="tbFlag" title="Pin the selected item, so it is never deleted" aria-label="Pin" data-icon="pin" disabled></button>
|
<button id="tbFlag" title="Pin the selected item, so it is never deleted" aria-label="Pin" data-icon="pin" disabled></button>
|
||||||
</div>
|
</div>
|
||||||
<span class="grow"></span>
|
<span class="grow"></span>
|
||||||
<input type="search" id="epSearch" placeholder="Search items…">
|
<span class="clearable" id="epBox"><input type="search" id="epSearch" placeholder="Search items…"><button type="button" class="clearx" title="Clear" aria-label="Clear" data-icon="close"></button></span>
|
||||||
<div class="tgroup" id="admintools">
|
<div class="tgroup" id="admintools">
|
||||||
<button id="prefs" title="Settings" aria-label="Settings" data-icon="settings"></button>
|
<button id="prefs" title="Settings" aria-label="Settings" data-icon="settings"></button>
|
||||||
<a id="admin" href="/admin" title="Admin: the server's settings, accounts and the log" aria-label="Admin" data-icon="admin"></a>
|
<a id="admin" href="/admin" title="Admin: the server's settings, accounts and the log" aria-label="Admin" data-icon="admin"></a>
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
</header>
|
</header>
|
||||||
<div id="shell">
|
<div id="shell">
|
||||||
<aside id="sidebar">
|
<aside id="sidebar">
|
||||||
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
|
<div class="searchwrap"><span class="clearable"><input type="search" id="feedFilter" placeholder="Filter feeds…"><button type="button" class="clearx" title="Clear" aria-label="Clear" data-icon="close"></button></span></div>
|
||||||
<div id="feedlist"></div>
|
<div id="feedlist"></div>
|
||||||
<div class="sidefoot">
|
<div class="sidefoot">
|
||||||
<div class="who"><span id="who"></span><button id="signout" title="Sign out" aria-label="Sign out" data-icon="signout"></button></div>
|
<div class="who"><span id="who"></span><button id="signout" title="Sign out" aria-label="Sign out" data-icon="signout"></button></div>
|
||||||
|
|||||||
15
web/signals-dark.svg
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<!-- signals.svg's dark appearance: the same layers in the same places, only recoloured. -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1d4468"/>
|
||||||
|
<stop offset="1" stop-color="#0c2238"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||||
|
<g id="waves" fill="none" stroke="#8fc2ea" stroke-width="112" stroke-linecap="round">
|
||||||
|
<path d="M293 252A340 340 0 0 0 293 772M731 252A340 340 0 0 1 731 772" opacity=".5"/>
|
||||||
|
<path d="M377 351A210 210 0 0 0 377 673M647 351A210 210 0 0 1 647 673" opacity=".85"/>
|
||||||
|
</g>
|
||||||
|
<g id="point"><circle cx="512" cy="512" r="96" fill="#ff6a1a"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 779 B |
17
web/signals.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<!-- The Signals folder's tile, drawn as logo.svg is (square, opaque, one background and flat
|
||||||
|
hard-edged layers, nothing thin enough to vanish at 32px) from the page's own Signals
|
||||||
|
glyph: waves going out from a point, the point in the needle's orange. Each <g> is a layer. -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#dff0fb"/>
|
||||||
|
<stop offset="1" stop-color="#9fccef"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||||
|
<g id="waves" fill="none" stroke="#2f6aa0" stroke-width="112" stroke-linecap="round">
|
||||||
|
<path d="M293 252A340 340 0 0 0 293 772M731 252A340 340 0 0 1 731 772" opacity=".5"/>
|
||||||
|
<path d="M377 351A210 210 0 0 0 377 673M647 351A210 210 0 0 1 647 673" opacity=".85"/>
|
||||||
|
</g>
|
||||||
|
<g id="point"><circle cx="512" cy="512" r="96" fill="#ff5500"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 976 B |
@@ -53,6 +53,16 @@ async function drawServer(){
|
|||||||
<input type="number" id="gart" min="0" value="${g.art_cache_mb}">
|
<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
|
<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>
|
instead of each publisher. Over this, what has gone longest unshown is dropped first.</span></div>
|
||||||
|
<div class="field"><label class="check"><input type="checkbox" id="gsig"${g.signals_on_server ? ' checked' : ''}${g.typesafe_server_key ? '' : ' disabled'}>
|
||||||
|
Signals for everyone, on this server's TypeSafe key</label>
|
||||||
|
<span class="hint">${g.typesafe_server_key
|
||||||
|
? 'On, everyone has Signals and every item read for one is a call on this server\'s key. Off, each person turns Signals on in their own Settings with a key of their own.'
|
||||||
|
: 'This server has no TypeSafe key (<code>TYPESAFE_KEY</code>), so each person uses their own.'}</span></div>
|
||||||
|
<div class="field"><label for="gsigdays">Signals look back (days)</label>
|
||||||
|
<input type="number" id="gsigdays" min="1" max="365" value="${g.signal_days}">
|
||||||
|
<span class="hint">How far back a new Signal reads. Each item read is a call to TypeSafe, so a
|
||||||
|
longer look back costs more, once for each new Signal. Editing a Signal reads again only what
|
||||||
|
it had picked out.</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>`;
|
||||||
@@ -64,7 +74,10 @@ async function drawServer(){
|
|||||||
media_types: $<HTMLInputElement>('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
|
media_types: $<HTMLInputElement>('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
|
||||||
max_total_gb: Number($<HTMLInputElement>('#gquota').value) || 0,
|
max_total_gb: Number($<HTMLInputElement>('#gquota').value) || 0,
|
||||||
max_age_days: Number($<HTMLInputElement>('#gage').value) || 0,
|
max_age_days: Number($<HTMLInputElement>('#gage').value) || 0,
|
||||||
art_cache_mb: Math.max(0, Number($<HTMLInputElement>('#gart').value) || 0)})});
|
art_cache_mb: Math.max(0, Number($<HTMLInputElement>('#gart').value) || 0),
|
||||||
|
signal_days: Math.min(365, Math.max(1, Number($<HTMLInputElement>('#gsigdays').value) || 7)),
|
||||||
|
// Left alone while the server has no key to offer, rather than switched off.
|
||||||
|
...($<HTMLInputElement>('#gsig').disabled ? {} : {signals_on_server: $<HTMLInputElement>('#gsig').checked})})});
|
||||||
toast('Settings saved');
|
toast('Settings saved');
|
||||||
}catch(e){ toast(why(e), true); }
|
}catch(e){ toast(why(e), true); }
|
||||||
};
|
};
|
||||||
@@ -249,7 +262,7 @@ function drawLogView(){
|
|||||||
<option value="WARN">Warnings and errors</option>
|
<option value="WARN">Warnings and errors</option>
|
||||||
<option value="ERROR">Errors only</option>
|
<option value="ERROR">Errors only</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="search" id="logq" class="grow" placeholder="Filter…">
|
${searchBox('<input type="search" id="logq" placeholder="Filter…">','grow')}
|
||||||
<label class="check" style="margin:0"><input type="checkbox" id="logfollow" checked> Follow</label>
|
<label class="check" style="margin:0"><input type="checkbox" id="logfollow" checked> Follow</label>
|
||||||
<button class="btn ico" id="logcopy" title="Copy what is showing" aria-label="Copy what is showing">${ICON.copy}</button>
|
<button class="btn ico" id="logcopy" title="Copy what is showing" aria-label="Copy what is showing">${ICON.copy}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -100,13 +100,23 @@ const KIND_WORD: Record<string,string>={All:'feed',Podcasts:'podcast',Blogs:'blo
|
|||||||
/// categories, and a category has a page of its own, its subcategories along the top of its
|
/// 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
|
/// 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.
|
/// every page, and the search box looks through the lot. One fetch draws all of it.
|
||||||
async function renderDirectory(url: string,box: HTMLElement){
|
async function renderDirectory(url: string,box: HTMLElement,counted: (n: number)=>void){
|
||||||
// Each visit starts at the front, as going back to a tab does.
|
// Each visit starts at the front, as going back to a tab does.
|
||||||
dirCat=null; dirSub=null; dirShow=null;
|
dirCat=null; dirSub=null; dirShow=null;
|
||||||
dirError='';
|
dirError='';
|
||||||
try{ dirRows=await api(url)||[]; }catch(e){ dirRows=[]; dirError=why(e); }
|
// Drawn at once from what was kept or fetched ahead, then again only if the catalogue changed
|
||||||
|
// (#248); with nothing kept, a spinner until it comes.
|
||||||
|
const kept=pages.get(url);
|
||||||
|
if(kept){ dirRows=kept; drawDirectory(box); counted(dirRows.length); }
|
||||||
|
else listLoading(box);
|
||||||
|
try{
|
||||||
|
const fresh=await fetchPage(url)||[];
|
||||||
|
if(kept&&JSON.stringify(kept)===JSON.stringify(fresh)) return;
|
||||||
|
if(!box.isConnected) return;
|
||||||
|
dirRows=fresh;
|
||||||
|
}catch(e){ if(kept) return; dirRows=[]; dirError=why(e); }
|
||||||
drawDirectory(box);
|
drawDirectory(box);
|
||||||
return dirRows.length;
|
counted(dirRows.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `focus` is the control to put the keyboard back on, since the whole page is redrawn.
|
/// `focus` is the control to put the keyboard back on, since the whole page is redrawn.
|
||||||
@@ -272,10 +282,9 @@ async function renderListed(v: {title: string, icon: string, url?: string, blurb
|
|||||||
syncTools(null);
|
syncTools(null);
|
||||||
if(v===VIEWS[':directory']){
|
if(v===VIEWS[':directory']){
|
||||||
$<HTMLInputElement>('#epSearch').placeholder='Find a feed…';
|
$<HTMLInputElement>('#epSearch').placeholder='Find a feed…';
|
||||||
box.innerHTML='<div id="directory"><p class="hint">Loading…</p></div>';
|
box.innerHTML='<div id="directory"></div>';
|
||||||
$('#count').textContent=v.title;
|
$('#count').textContent=v.title;
|
||||||
const n=await renderDirectory(v.url!,$('#directory'));
|
await renderDirectory(v.url!,$('#directory'),n=>{ if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`; });
|
||||||
if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$<HTMLInputElement>('#epSearch').placeholder='Search items…';
|
$<HTMLInputElement>('#epSearch').placeholder='Search items…';
|
||||||
@@ -284,19 +293,30 @@ async function renderListed(v: {title: string, icon: string, url?: string, blurb
|
|||||||
<div class="art">${v.icon}</div>
|
<div class="art">${v.icon}</div>
|
||||||
<div class="meta"><h2>${v.title}</h2><div class="sub">${v.blurb}</div></div>
|
<div class="meta"><h2>${v.title}</h2><div class="sub">${v.blurb}</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`;
|
<div class="childlist" id="listening"></div>`;
|
||||||
$('#count').textContent=v.title;
|
$('#count').textContent=v.title;
|
||||||
// The search box looks through these too, title and text, as it does a feed's items (#127).
|
// The search box looks through these too, title and text, as it does a feed's items (#127).
|
||||||
const n=await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url!,$('#listening',box));
|
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')}`;
|
n=>{ if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`; });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Currently Listening: episodes you started and have not finished, across every feed you
|
/// Currently Listening: episodes you started and have not finished, across every feed you
|
||||||
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
|
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
|
||||||
/// where you left off, not another way to browse. The one in the player pauses instead.
|
/// where you left off, not another way to browse. The one in the player pauses instead.
|
||||||
async function renderListening(url: string,box: HTMLElement){
|
async function renderListening(url: string,box: HTMLElement,counted: (n: number)=>void){
|
||||||
let rows: Entry[]=[];
|
// Drawn at once from what was kept or fetched ahead, then again only if it changed (#248): it
|
||||||
try{ rows=(await api(url)).entries||[]; }catch{}
|
// took over a second to answer. With nothing kept, a spinner until it comes.
|
||||||
|
const kept: {entries?: Entry[]}|undefined=pages.get(url);
|
||||||
|
if(kept){ drawListening(box,kept.entries||[]); counted((kept.entries||[]).length); }
|
||||||
|
else listLoading(box);
|
||||||
|
let fresh: {entries?: Entry[]};
|
||||||
|
try{ fresh=await fetchPage(url); }catch{ if(kept) return; fresh={}; }
|
||||||
|
if(kept&&JSON.stringify(kept)===JSON.stringify(fresh)) return;
|
||||||
|
if(!box.isConnected) return;
|
||||||
|
drawListening(box,fresh.entries||[]);
|
||||||
|
counted((fresh.entries||[]).length);
|
||||||
|
}
|
||||||
|
function drawListening(box: HTMLElement,rows: Entry[]){
|
||||||
box.innerHTML=rows.length?'':S.q?`<p class="hint">Nothing you are listening to mentions “${esc(S.q)}”.</p>`
|
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>';
|
:'<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){
|
||||||
@@ -315,7 +335,6 @@ async function renderListening(url: string,box: HTMLElement){
|
|||||||
paintListenRow(el);
|
paintListenRow(el);
|
||||||
box.appendChild(el);
|
box.appendChild(el);
|
||||||
}
|
}
|
||||||
return rows.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row's time left, progress and play button, taken from the player when it is the one in it.
|
/// One row's time left, progress and play button, taken from the player when it is the one in it.
|
||||||
@@ -348,6 +367,8 @@ async function forget(e: Entry){
|
|||||||
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
|
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
|
||||||
{method:'POST',body:JSON.stringify({secs:0})});
|
{method:'POST',body:JSON.stringify({secs:0})});
|
||||||
}catch(err){ toast(why(err),true); }
|
}catch(err){ toast(why(err),true); }
|
||||||
|
// The kept list still has it; drawn from that, it would show again until the refresh came.
|
||||||
|
for(const k of [...pages.keys()]) if(k.startsWith(VIEWS[':listening'].url!)) pages.delete(k);
|
||||||
if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,6 +440,18 @@ 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>
|
||||||
|
${S.me?.signals==='server'?`<div class="field"><label>Signals</label>
|
||||||
|
<span class="hint">Signals pick what matters out of everything you subscribe to. This server
|
||||||
|
pays for them, so there is nothing to set up.</span></div>`:`<div class="field">
|
||||||
|
<label class="check"><input type="checkbox" id="ssigon"${S.me?.typesafe_key_set?' checked':''}> Signals</label>
|
||||||
|
<span class="hint">Signals pick what matters out of everything you subscribe to. To use Signals
|
||||||
|
you will need an API key from <a href="https://typesafe.ai" target="_blank" rel="noopener">typesafe.ai</a>.</span>
|
||||||
|
<div id="ssigkey"${S.me?.typesafe_key_set?'':' hidden'}>
|
||||||
|
<label for="stskey">TypeSafe key</label>
|
||||||
|
<div class="inline"><input type="password" id="stskey" autocomplete="off" spellcheck="false"
|
||||||
|
placeholder="${S.me?.typesafe_key_set?'Saved. Paste a new one to replace it':'Paste your key'}">
|
||||||
|
<button class="btn ico" id="stsforget" title="Forget the key" aria-label="Forget the key"${S.me?.typesafe_key_set?'':' hidden'}>${ICON.trash}</button></div>
|
||||||
|
<span class="hint">Checked with TypeSafe before it is saved, kept on this server and never shown again.</span></div></div>`}
|
||||||
<div class="field boxed"><label for="tokname">API tokens</label>
|
<div class="field boxed"><label for="tokname">API tokens</label>
|
||||||
<div id="tokens"></div>
|
<div id="tokens"></div>
|
||||||
<div class="inline"><input type="text" id="tokname" placeholder="What it is for">
|
<div class="inline"><input type="text" id="tokname" placeholder="What it is for">
|
||||||
@@ -448,6 +481,31 @@ async function prefsModal(){
|
|||||||
$<HTMLInputElement>('#tokval').select();
|
$<HTMLInputElement>('#tokval').select();
|
||||||
}catch(err){ toast(why(err),true); }
|
}catch(err){ toast(why(err),true); }
|
||||||
};
|
};
|
||||||
|
// Your own key, unless the server's is used for everyone (#271). Signals are on once TypeSafe
|
||||||
|
// has taken a key, and off again when it is forgotten.
|
||||||
|
if(S.me?.signals!=='server'){
|
||||||
|
const saveKey=async(typesafe_key: string|null)=>{
|
||||||
|
try{
|
||||||
|
await api('/api/me',{method:'PATCH',body:JSON.stringify({typesafe_key})});
|
||||||
|
if(S.me) S.me.typesafe_key_set=!!typesafe_key;
|
||||||
|
$<HTMLInputElement>('#stskey').value='';
|
||||||
|
$<HTMLInputElement>('#stskey').placeholder=typesafe_key?'Saved. Paste a new one to replace it':'Paste your key';
|
||||||
|
$<HTMLButtonElement>('#stsforget').hidden=!typesafe_key;
|
||||||
|
$<HTMLInputElement>('#ssigon').checked=!!typesafe_key; $('#ssigkey').hidden=!typesafe_key;
|
||||||
|
toast(typesafe_key?'Signals are on':'Signals are off');
|
||||||
|
renderFeeds();
|
||||||
|
}catch(err){ toast(why(err),true); }
|
||||||
|
};
|
||||||
|
$<HTMLInputElement>('#ssigon').onchange=(e: Event)=>{
|
||||||
|
const on=(e.target as HTMLInputElement).checked;
|
||||||
|
if(on){ $('#ssigkey').hidden=false; $<HTMLInputElement>('#stskey').focus(); return; }
|
||||||
|
if(!S.me?.typesafe_key_set){ $('#ssigkey').hidden=true; return; }
|
||||||
|
if(confirm('Turn Signals off and forget your TypeSafe key? Your signals are kept for when you turn them on again.')) saveKey(null);
|
||||||
|
else (e.target as HTMLInputElement).checked=true;
|
||||||
|
};
|
||||||
|
$<HTMLInputElement>('#stskey').onchange=(e: Event)=>{ const k=(e.target as HTMLInputElement).value.trim(); if(k) saveKey(k); };
|
||||||
|
$<HTMLButtonElement>('#stsforget').onclick=()=>$<HTMLInputElement>('#ssigon').click();
|
||||||
|
}
|
||||||
$<HTMLInputElement>('#sblock').onchange=async (e: Event)=>{
|
$<HTMLInputElement>('#sblock').onchange=async (e: Event)=>{
|
||||||
const blocked=splitWords((e.target as HTMLInputElement).value);
|
const blocked=splitWords((e.target as HTMLInputElement).value);
|
||||||
try{
|
try{
|
||||||
@@ -531,13 +589,23 @@ function settingsModal(f: Feed, newUrl?: string){
|
|||||||
? `It comes from ${esc(parent.title||parent.id)}, and follows your settings there until you save these.`
|
? `It comes from ${esc(parent.title||parent.id)}, and follows your settings there until you save these.`
|
||||||
: 'Your settings for this feed. Everyone else who reads it keeps their own.';
|
: 'Your settings for this feed. Everyone else who reads it keeps their own.';
|
||||||
const sw=(input: string, text: string, cls='')=>`<label class="switch${cls}"><span>${text}</span><span class="sw">${input}<i></i></span></label>`;
|
const sw=(input: string, text: string, cls='')=>`<label class="switch${cls}"><span>${text}</span><span class="sw">${input}<i></i></span></label>`;
|
||||||
|
// Each part named in a column of its own, the controls beside it (#290): the dialog had grown one
|
||||||
|
// captioned setting at a time into a single column three screens long on a phone.
|
||||||
|
const sect=(title: string, body: string)=>`<section class="fsec"><h4>${title}</h4><div class="fsecin">${body}</div></section>`;
|
||||||
|
const these=isGroup?'these shows':'this show';
|
||||||
|
// The places in the sidebar this show's items go to, drawn as the sidebar draws them (#288, #289).
|
||||||
|
const place=(id: string, key: 'hide_from_all'|'hide_from_signals', icon: string, title: string)=>{
|
||||||
|
const byList=f.group?S.feeds.find(g=>g.id===f.group&&g[key]):undefined;
|
||||||
|
return sw(`<input type="checkbox" role="switch" id="${id}"${shownIn(f,key)?' checked':''}${byList?' disabled':''}>`,
|
||||||
|
`<span class="fico" aria-hidden="true">${icon}</span>${title}`,' fplace');
|
||||||
|
};
|
||||||
|
const listHides=f.group?S.feeds.find(g=>g.id===f.group&&(g.hide_from_all||g.hide_from_signals)):undefined;
|
||||||
openModal(`<div class="fsetbody">
|
openModal(`<div class="fsetbody">
|
||||||
<header class="fsethead">${artHTML(f.image,name)}<div><h3>${esc(name)}</h3><p>${whose}</p></div></header>
|
<header class="fsethead">${artHTML(f.image,name)}<div><h3>${esc(name)}</h3><p>${whose}</p></div></header>
|
||||||
<div class="field faddr"><label for="surl">Address</label>
|
${sect('Address',`<div class="inline"><input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" aria-label="Address"${admin?'':' readonly'}>
|
||||||
<div class="inline"><input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false"${admin?'':' readonly'}>
|
|
||||||
<button type="button" class="btn ico" id="scopy" title="Copy the address" aria-label="Copy the address">${ICON.copy}</button></div>
|
<button type="button" class="btn ico" id="scopy" title="Copy the address" aria-label="Copy the address">${ICON.copy}</button></div>
|
||||||
${newUrl?'<p class="moved">The feed says it has moved here. Save to follow it.</p>':''}</div>
|
${newUrl?'<p class="moved">The feed says it has moved here. Save to follow it.</p>':''}`)}
|
||||||
<div class="fdl">${sw(`<input type="checkbox" role="switch" id="sauto"${f.auto_download?' checked':''}>`,'Download automatically',' lead')}
|
${sect('Downloads',`${sw(`<input type="checkbox" role="switch" id="sauto"${f.auto_download?' checked':''}>`,'Download automatically',' lead')}
|
||||||
<div class="fdeps${f.auto_download?'':' off'}" id="sdeps"><div><div class="fdepsin">
|
<div class="fdeps${f.auto_download?'':' off'}" id="sdeps"><div><div class="fdepsin">
|
||||||
<div class="tabs seg" role="radiogroup" aria-label="How many to download">${
|
<div class="tabs seg" role="radiogroup" aria-label="How many to download">${
|
||||||
[['default',`Default (${globalMax||'all'})`],['newest','Newest'],['all','All']].map(([v,t])=>
|
[['default',`Default (${globalMax||'all'})`],['newest','Newest'],['all','All']].map(([v,t])=>
|
||||||
@@ -548,27 +616,29 @@ function settingsModal(f: Feed, newUrl?: string){
|
|||||||
<div class="chips" id="skw">${chipsHTML(f.keywords,'skwin')}</div>
|
<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>
|
<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')}
|
${sw(`<input type="checkbox" role="switch" id="sexp"${f.allow_explicit?' checked':''}>`,'Include explicit items')}
|
||||||
</div></div></div>
|
</div></div></div>`)}
|
||||||
<div class="ffield fdead">${sw(`<input type="checkbox" role="switch" id="sdead"${f.deadair?' checked':''}>`,'DeadAir',' lead')}
|
${sect('Playback',`${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
|
<p class="cap">Shrinks long silences in ${these} to a beat, so episodes finish sooner. Leave it off
|
||||||
episodes finish sooner and every voice keeps its own pace. Leave it off for stories whose pauses
|
for stories whose pauses are part of the telling.</p>
|
||||||
are part of the telling.</p>
|
|
||||||
<div class="dpause">
|
<div class="dpause">
|
||||||
<div class="dphead"><label class="flabel" for="sdpause">Breathing room</label><output id="sdpauseout" for="sdpause"></output></div>
|
<div class="dphead"><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="dpslide"><input type="range" id="sdpause" aria-label="Breathing room" 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>
|
<div class="dpends" aria-hidden="true"><span>Brisk</span><span>Relaxed</span></div></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>
|
${sect('Where it shows',`<div class="fplaces">${place('sinall','hide_from_all',ICON.all,'All Subscriptions')}${
|
||||||
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
|
signalsOn()?place('sinsig','hide_from_signals',`<span class="fsig">${tile('signals')}</span>`,'Signals'):''}</div>
|
||||||
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
|
<p class="cap">${listHides
|
||||||
<p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along
|
?`Off where ${esc(listHides.title||listHides.id)} is off; its settings bring them back.`
|
||||||
with what Settings hides everywhere.</p></div>`}
|
:`Off leaves ${these} out of that list and its count. ${isGroup?'Their':'Its'} own page still has everything.`}</p>
|
||||||
<div class="ffield">${sw(`<input type="checkbox" role="switch" id="sprivate"${f.private||f.private_by_address?' checked':''}${
|
${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 everywhere and not downloaded for you.</p></div>`}`)}
|
||||||
|
${sect('Directory',`${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')}
|
f.private_by_address||(f.private&&!admin)?' disabled':''}>`,'Private',' lead')}
|
||||||
<p class="cap">${f.private_by_address
|
<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.'
|
?'Its address or login shows it is someone’s own, so it is never shared with the directory.'
|
||||||
:f.private&&!admin
|
:f.private&&!admin
|
||||||
?'Never shared with the directory. Only an admin can share it again.'
|
?'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>
|
:`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 class="cardacts fsetfoot"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
<div class="cardacts fsetfoot"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`, false, 'fset');
|
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`, false, 'fset');
|
||||||
@@ -609,7 +679,7 @@ function settingsModal(f: Feed, newUrl?: string){
|
|||||||
if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); };
|
if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); };
|
||||||
};
|
};
|
||||||
for(const r of $$<HTMLInputElement>('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); };
|
for(const r of $$<HTMLInputElement>('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); };
|
||||||
// Breathing room only matters with DeadAir on: greyed out, not hidden, so it can be seen beforehand.
|
// The pause slider only matters with DeadAir on: greyed out, not hidden, so it can be seen beforehand.
|
||||||
$<HTMLInputElement>('#sdead').onchange=(e: Event)=>{ $<HTMLInputElement>('#sdpause').disabled=!(e.target as HTMLInputElement).checked; };
|
$<HTMLInputElement>('#sdead').onchange=(e: Event)=>{ $<HTMLInputElement>('#sdpause').disabled=!(e.target as HTMLInputElement).checked; };
|
||||||
const pause=$<HTMLInputElement>('#sdpause'), pauseText=()=>{ const s=`${Number(pause.value)/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); };
|
const pause=$<HTMLInputElement>('#sdpause'), pauseText=()=>{ const s=`${Number(pause.value)/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); };
|
||||||
pause.oninput=pauseText; pauseText();
|
pause.oninput=pauseText; pauseText();
|
||||||
@@ -658,6 +728,11 @@ function settingsModal(f: Feed, newUrl?: string){
|
|||||||
// Sent only when it changed: anyone may mark a feed private, but only an admin unmark it.
|
// Sent only when it changed: anyone may mark a feed private, but only an admin unmark it.
|
||||||
const priv=$<HTMLInputElement>('#sprivate');
|
const priv=$<HTMLInputElement>('#sprivate');
|
||||||
if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked;
|
if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked;
|
||||||
|
// Sent only when changed, and not while the OPML decides it.
|
||||||
|
for(const [id,key] of [['#sinall','hide_from_all'],['#sinsig','hide_from_signals']] as const){
|
||||||
|
const x=document.querySelector<HTMLInputElement>(id);
|
||||||
|
if(x&&!x.disabled&&x.checked===!!f[key]) patch[key]=!x.checked;
|
||||||
|
}
|
||||||
// The address is the same for everyone: an admin's to change, for a feed that moved, and the
|
// 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.
|
// API refuses it from anyone else. The folder and category are not this dialog's at all.
|
||||||
if(admin) patch.url=$<HTMLInputElement>('#surl').value.trim();
|
if(admin) patch.url=$<HTMLInputElement>('#surl').value.trim();
|
||||||
@@ -757,7 +832,7 @@ $('#feedlist').onkeydown=(ev: KeyboardEvent)=>{
|
|||||||
const row=(ev.target as Element).closest<HTMLElement>('[data-id]'); if(!row) return;
|
const row=(ev.target as Element).closest<HTMLElement>('[data-id]'); if(!row) return;
|
||||||
const id=row.dataset.id!;
|
const id=row.dataset.id!;
|
||||||
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
|
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
|
||||||
else if(row.classList.contains('group')&&
|
else if(row.matches('.group,.sigs')&&
|
||||||
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
|
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
|
||||||
else return;
|
else return;
|
||||||
ev.preventDefault(); ev.stopPropagation();
|
ev.preventDefault(); ev.stopPropagation();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ let sse: EventSource;
|
|||||||
/// the app is in the background, when everything is missed (#168).
|
/// the app is in the background, when everything is missed (#168).
|
||||||
const catchUp=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
|
const catchUp=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
|
||||||
loadFeeds(true).catch(()=>{});
|
loadFeeds(true).catch(()=>{});
|
||||||
if(S.feed) loadEntries().catch(()=>{});
|
if(S.feed) loadEntries(false,true).catch(()=>{});
|
||||||
},300); }; })();
|
},300); }; })();
|
||||||
document.addEventListener('visibilitychange',()=>{ if(!document.hidden) catchUp(); });
|
document.addEventListener('visibilitychange',()=>{ if(!document.hidden) catchUp(); });
|
||||||
let dropped=false;
|
let dropped=false;
|
||||||
@@ -16,11 +16,15 @@ function connect(){
|
|||||||
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
|
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
|
||||||
const soon=(fn: ()=>void,ms=500)=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
const soon=(fn: ()=>void,ms=500)=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
||||||
// Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219).
|
// Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219).
|
||||||
const refreshEntries=soon(()=>{ if(S.feed) loadEntries().catch(()=>{}); });
|
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(false,true).catch(()=>{}); });
|
||||||
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
||||||
const mine=(id: string)=>S.feeds.some(f=>f.id===id);
|
const mine=(id: string)=>S.feeds.some(f=>f.id===id);
|
||||||
|
// The lists a change made stale, fetched again behind, once a burst of rows has passed (#247).
|
||||||
|
const preloadSoon=soon(preloadLists,2000);
|
||||||
sse.onmessage=m=>{
|
sse.onmessage=m=>{
|
||||||
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
||||||
|
// Someone else's download is theirs: its file's name and its failure stay off this page (#294).
|
||||||
|
if((ev.ev==='progress'||ev.ev==='download_error')&&!mine(ev.feed)) return;
|
||||||
if(ev.ev==='progress'){
|
if(ev.ev==='progress'){
|
||||||
const pct=ev.total?ev.done/ev.total*100:0;
|
const pct=ev.total?ev.done/ev.total*100:0;
|
||||||
// Only the row actually downloading. Without the enclosure id this used to paint
|
// Only the row actually downloading. Without the enclosure id this used to paint
|
||||||
@@ -41,27 +45,33 @@ function connect(){
|
|||||||
if(!mine(ev.feed)) return;
|
if(!mine(ev.feed)) return;
|
||||||
if(ev.saved_s>0&&document.querySelector('.dapend')) toast(`DeadAir is done: ${shorter(ev.saved_s)}`);
|
if(ev.saved_s>0&&document.querySelector('.dapend')) toast(`DeadAir is done: ${shorter(ev.saved_s)}`);
|
||||||
refreshEntries();
|
refreshEntries();
|
||||||
if(S.sel) loadEntries().then(()=>{ const e=cur(); if(e) showDetail(e); }).catch(()=>{});
|
if(S.sel) loadEntries(false,true).then(()=>{ const e=cur(); if(e) showDetail(e); }).catch(()=>{});
|
||||||
}
|
}
|
||||||
else if(ev.ev==='download_error'){
|
else if(ev.ev==='download_error'){
|
||||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||||
if(bar) bar.classList.remove('live');
|
if(bar) bar.classList.remove('live');
|
||||||
toast('Download failed: '+ev.msg,true); refreshEntries();
|
toast('Download failed: '+ev.msg,true); refreshEntries();
|
||||||
}
|
}
|
||||||
|
// Jev picked items out for someone's Signals: theirs are fetched again, and the one open.
|
||||||
|
else if(ev.ev==='signal_hits'){
|
||||||
|
if(!S.signals.length) return;
|
||||||
|
refreshSignals();
|
||||||
|
if(signalOf(S.feed)) refreshEntries();
|
||||||
|
}
|
||||||
// A spinner on the feed's row while it is checked, in place of a toast per feed (issue #37).
|
// A spinner on the feed's row while it is checked, in place of a toast per feed (issue #37).
|
||||||
else if(ev.ev==='feed_start') setScanning(ev.feed,true);
|
else if(ev.ev==='feed_start') setScanning(ev.feed,true);
|
||||||
else if(ev.ev==='feed_skip') setScanning(ev.feed,false);
|
else if(ev.ev==='feed_skip') setScanning(ev.feed,false);
|
||||||
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;
|
||||||
if(ev.feed===S.feed||S.feed===':all') refreshEntries();
|
if(ev.feed===S.feed||S.feed===':all'||S.feeds.some(f=>f.id===ev.feed&&f.group===S.feed)) 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);
|
else if(ev.ev==='feed_error') setScanning(ev.feed,false);
|
||||||
// The server sends a feed's new row after anything changes it (counts, error, last check),
|
// 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.
|
// only to those who subscribe; the page used to reload the whole list after each event.
|
||||||
else if(ev.ev==='feed_row') patchFeed(ev.row);
|
else if(ev.ev==='feed_row'){ patchFeed(ev.row); forgetLists(ev.row.id); preloadSoon(); }
|
||||||
// Only a scan that checked something: the scheduler scans every minute, due or not, and
|
// 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).
|
// 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
|
// Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the
|
||||||
@@ -73,4 +83,4 @@ function connect(){
|
|||||||
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
|
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
|
||||||
}
|
}
|
||||||
connect();
|
connect();
|
||||||
loadFeeds().catch(()=>{});
|
loadFeeds().then(preloadLists).catch(()=>{});
|
||||||
|
|||||||
@@ -5,16 +5,17 @@ function renderFeed(){
|
|||||||
box.classList.remove('plain');
|
box.classList.remove('plain');
|
||||||
const v=VIEWS[S.feed ?? ''];
|
const v=VIEWS[S.feed ?? ''];
|
||||||
if(v&&v.url) return renderListed(v);
|
if(v&&v.url) return renderListed(v);
|
||||||
|
if(S.feed===SIGNALS) return renderSignals();
|
||||||
const f=v?null:S.feeds.find(x=>x.id===S.feed);
|
const f=v?null:S.feeds.find(x=>x.id===S.feed);
|
||||||
|
const sg=signalOf(S.feed);
|
||||||
$<HTMLButtonElement>('#tbRemove').disabled=!f;
|
$<HTMLButtonElement>('#tbRemove').disabled=!f;
|
||||||
syncTools(null);
|
syncTools(null);
|
||||||
if(!v&&!f){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
|
if(!v&&!f&&!sg){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
|
||||||
const name=f?(f.title||f.id):v.title;
|
const name=f?(f.title||f.id):sg?sg.name:v.title;
|
||||||
$<HTMLInputElement>('#epSearch').placeholder=`Search ${name}…`;
|
$<HTMLInputElement>('#epSearch').placeholder=`Search ${name}…`;
|
||||||
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
|
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
|
||||||
if(kids.length){ box.classList.add('plain'); renderGroup(f!,kids); return; }
|
const unreadAll=unreadInAll();
|
||||||
const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
box.innerHTML = (f&&kids.length ? groupHead(f,kids) : f ? `
|
||||||
box.innerHTML = (f ? `
|
|
||||||
<div class="fhead slim">
|
<div class="fhead slim">
|
||||||
${artHTML(f.image,name)}
|
${artHTML(f.image,name)}
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
@@ -32,11 +33,12 @@ function renderFeed(){
|
|||||||
<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>
|
||||||
|
${keepHTML(f)}
|
||||||
<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>
|
||||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>` : `
|
</div>` : sg ? signalHead(sg) : `
|
||||||
<div class="fhead slim">
|
<div class="fhead slim">
|
||||||
<div class="art">${v.icon}</div>
|
<div class="art">${v.icon}</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
@@ -58,7 +60,8 @@ function renderFeed(){
|
|||||||
|
|
||||||
const pane=document.createElement('div');
|
const pane=document.createElement('div');
|
||||||
pane.id='split';
|
pane.id='split';
|
||||||
if(f) pane.className='one';
|
// One feed's list leaves out the feed column; an OPML's shows are told apart by it.
|
||||||
|
if(f&&!kids.length) pane.className='one';
|
||||||
pane.innerHTML='<div id="list">'+sortHead()+
|
pane.innerHTML='<div id="list">'+sortHead()+
|
||||||
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
|
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
|
||||||
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort!));
|
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort!));
|
||||||
@@ -69,7 +72,8 @@ function renderFeed(){
|
|||||||
S.sel=null;
|
S.sel=null;
|
||||||
showDetail(null);
|
showDetail(null);
|
||||||
|
|
||||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a!,f):allAction(b.dataset.a!));
|
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a!,f):sg?signalAction(b.dataset.a!,sg):allAction(b.dataset.a!));
|
||||||
|
$$('#content .tabs button').forEach(b=>b.onpointerenter=b.ontouchstart=()=>{ if(S.feed) prefetch(S.feed,b.dataset.f!); });
|
||||||
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
|
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
|
||||||
S.filter=b.dataset.f!; S.offset=0;
|
S.filter=b.dataset.f!; S.offset=0;
|
||||||
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
|
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
|
||||||
@@ -96,26 +100,25 @@ function sortBy(col: string){
|
|||||||
S.offset=0; renderFeed(); loadEntries();
|
S.offset=0; renderFeed(); loadEntries();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An OPML subscription's page lists the feeds inside it rather than items, but keeps
|
/// An OPML subscription's page lists the items of the shows inside it together, as All
|
||||||
/// every action a normal feed has -- it is still an ordinary feed entry underneath.
|
/// Subscriptions does every feed's (#283), under the header a feed has, its buttons in the
|
||||||
|
/// same places: it is still an ordinary feed entry underneath.
|
||||||
// A Patreon creator split into its shows is drawn like an OPML, and named for what it is.
|
// A Patreon creator split into its shows is drawn like an OPML, and named for what it is.
|
||||||
const isPatreon=(f: {url: string}|null)=>/patreon\.com\//.test(f&&f.url||'');
|
const isPatreon=(f: {url: string}|null)=>/patreon\.com\//.test(f&&f.url||'');
|
||||||
|
|
||||||
function renderGroup(f: Feed,kids: Feed[]){
|
function groupHead(f: Feed,kids: Feed[]){
|
||||||
const unread=kids.reduce((n,c)=>n+c.unread,0);
|
const unread=kids.reduce((n,c)=>n+c.unread,0);
|
||||||
const saved=kids.reduce((n,c)=>n+c.downloaded,0);
|
const saved=kids.reduce((n,c)=>n+c.downloaded,0);
|
||||||
const gone=kids.filter(c=>c.orphaned).length;
|
const gone=kids.filter(c=>c.orphaned).length;
|
||||||
$('#count').textContent=`${f.title||f.id}: ${kids.length} feed${kids.length===1?'':'s'}, ${unread} unread`;
|
return `
|
||||||
// The same header as a feed's, buttons in the same places: it is a feed underneath.
|
|
||||||
$('#content').innerHTML = `
|
|
||||||
<div class="fhead slim">
|
<div class="fhead slim">
|
||||||
${folderArt(f,kids)}
|
${folderArt(f,kids)}
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<h2>${esc(f.title||f.id)}</h2>
|
<h2>${esc(f.title||f.id)}</h2>
|
||||||
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}">${isPatreon(f)?'Patreon creator':'OPML subscription'}
|
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}">${isPatreon(f)?'Patreon creator':'OPML subscription'}
|
||||||
· ${plural(kids.length,'feed')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</div>
|
· ${plural(kids.length,'show')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</div>
|
||||||
${failBannerHTML(f)}
|
${failBannerHTML(f)}
|
||||||
${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer
|
${gone?`<div class="sub" style="color:var(--warn)">${gone} show${gone===1?' is':'s are'} no longer
|
||||||
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">
|
||||||
@@ -126,38 +129,18 @@ function renderGroup(f: Feed,kids: Feed[]){
|
|||||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
<div class="toolbar">
|
|
||||||
<input type="search" class="grow" id="kidSearch" placeholder="Search these feeds…">
|
|
||||||
<span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="childlist" id="kidlist"></div>`;
|
|
||||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a!,f));
|
|
||||||
wireFailBanner($('#content'),f);
|
|
||||||
const draw=()=>{
|
|
||||||
const q=($<HTMLInputElement>('#kidSearch').value||'').trim().toLowerCase();
|
|
||||||
const box=$('#kidlist'); box.innerHTML='';
|
|
||||||
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
|
|
||||||
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
|
|
||||||
for(const c of rows){
|
|
||||||
const el=document.createElement('div');
|
|
||||||
el.className='childrow';
|
|
||||||
el.innerHTML = artHTML(c.image,c.title||c.id)+
|
|
||||||
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+
|
|
||||||
`<small class="meta">${plural(c.entries,'item')} · ${c.downloaded} downloaded`+
|
|
||||||
(c.failing?` · <span style="color:var(--bad)" title="${esc(c.failing.reason)}">error</span>`
|
|
||||||
:c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
|
|
||||||
(c.orphaned?'<span class="tag">Gone</span>':'')+
|
|
||||||
(c.failing?`<span class="tag" style="color:var(--bad)" title="${esc(c.failing.reason)}">Error</span>`:'')+
|
|
||||||
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
|
|
||||||
el.onclick=()=>selectFeed(c.id);
|
|
||||||
box.appendChild(el);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
$<HTMLInputElement>('#kidSearch').oninput=draw;
|
|
||||||
draw();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep, for a show from an OPML or a Patreon creator: it stays yours, a show of its own, should
|
||||||
|
/// the list drop it or you leave the list (#280). A plus to keep it, a check once kept.
|
||||||
|
function keepHTML(f: Feed){
|
||||||
|
if(!f.managed||!f.group) return '';
|
||||||
|
const list=S.feeds.find(p=>p.id===f.group);
|
||||||
|
const name=esc(list?(list.title||list.id):f.group);
|
||||||
|
const title=f.kept?`Kept: stays on your list if ${name} drops it. Stop keeping it`:`Keep it on your list if ${name} drops it`;
|
||||||
|
return `<button class="btn ico" data-a="keep" title="${title}" aria-label="${f.kept?'Kept':'Keep'}" aria-pressed="${!!f.kept}">${f.kept?ICON.check:ICON.plus}</button>`;
|
||||||
|
}
|
||||||
async function feedAction(a: string,f: Feed){
|
async function feedAction(a: string,f: Feed){
|
||||||
if(a==='scan'){
|
if(a==='scan'){
|
||||||
spinStart(f.id);
|
spinStart(f.id);
|
||||||
@@ -172,6 +155,12 @@ async function feedAction(a: string,f: Feed){
|
|||||||
await loadFeeds(true); renderFeed();
|
await loadFeeds(true); renderFeed();
|
||||||
}catch(e){ toast(why(e),true); }
|
}catch(e){ toast(why(e),true); }
|
||||||
}
|
}
|
||||||
|
if(a==='keep'){
|
||||||
|
try{
|
||||||
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({kept:!f.kept})});
|
||||||
|
await loadFeeds(true); renderFeed();
|
||||||
|
}catch(e){ toast(why(e),true); }
|
||||||
|
}
|
||||||
if(a==='settings') settingsModal(f);
|
if(a==='settings') settingsModal(f);
|
||||||
if(a==='share') share(f.title||f.id, f.url, $('#content .acts [data-a="share"]'));
|
if(a==='share') share(f.title||f.id, f.url, $('#content .acts [data-a="share"]'));
|
||||||
if(a==='dl') downloadLatestModal(f);
|
if(a==='dl') downloadLatestModal(f);
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ function patchFeed(row: Feed){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadFeeds(keepSel?: boolean){
|
async function loadFeeds(keepSel?: boolean){
|
||||||
S.feeds = await api('/api/feeds');
|
// A Signal list that fails leaves the feeds to show.
|
||||||
|
[S.feeds, S.signals] = await Promise.all([api('/api/feeds'), api('/api/signals').catch((): Signal[]=>[])]);
|
||||||
// Only for a hint in a feed's settings, so once, on the page's first load, not on every reload
|
// 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).
|
// of the list (#103).
|
||||||
if(!keepSel) api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
|
if(!keepSel) api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
|
||||||
@@ -18,7 +19,7 @@ async function loadFeeds(keepSel?: boolean){
|
|||||||
// 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
|
||||||
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
|
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
|
||||||
if(!keepSel && S.feeds.length){
|
if(!keepSel && S.feeds.length){
|
||||||
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
|
const known = S.feed && (VIEWS[S.feed] || S.feed===SIGNALS || signalOf(S.feed) || S.feeds.some(f=>f.id===S.feed));
|
||||||
selectFeed(known ? S.feed! : ':all');
|
selectFeed(known ? S.feed! : ':all');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,12 @@ const VIEWS: Record<string,{title: string, icon: string, url?: string, blurb?: s
|
|||||||
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},
|
||||||
};
|
};
|
||||||
|
/// Whether a feed's items are in All Subscriptions, or in Signals: not when it, or the OPML or
|
||||||
|
/// creator it came from, is hidden from them (#288, #289). The server leaves out the same.
|
||||||
|
const shownIn=(f: Feed, key: 'hide_from_all'|'hide_from_signals')=>!f[key]&&!(f.group&&S.feeds.some(g=>g.id===f.group&&g[key]));
|
||||||
|
const inAll=(f: Feed)=>shownIn(f,'hide_from_all');
|
||||||
|
/// All Subscriptions' unread count.
|
||||||
|
const unreadInAll=()=>S.feeds.reduce((n,f)=>n+(inAll(f)?f.unread||0:0),0);
|
||||||
/// Feeds being checked right now, from the event stream, and since when: their rows, and the row
|
/// Feeds being checked right now, from the event stream, and since when: their rows, and the row
|
||||||
/// of a folder holding one, carry a spinner.
|
/// of a folder holding one, carry a spinner.
|
||||||
const scanning=new Map<string, number>();
|
const scanning=new Map<string, number>();
|
||||||
@@ -89,7 +96,7 @@ function renderFeeds(){
|
|||||||
const drawn=new Map<string,HTMLImageElement[]>();
|
const drawn=new Map<string,HTMLImageElement[]>();
|
||||||
for(const img of $$<HTMLImageElement>('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k)!.push(img); }
|
for(const img of $$<HTMLImageElement>('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k)!.push(img); }
|
||||||
list.innerHTML='';
|
list.innerHTML='';
|
||||||
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
|
const unreadAll=unreadInAll();
|
||||||
const places=document.createElement('div');
|
const places=document.createElement('div');
|
||||||
places.className='places';
|
places.className='places';
|
||||||
for(const [id,v] of Object.entries(VIEWS)){
|
for(const [id,v] of Object.entries(VIEWS)){
|
||||||
@@ -99,9 +106,11 @@ function renderFeeds(){
|
|||||||
el.innerHTML=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all'
|
el.innerHTML=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all'
|
||||||
?`<span class="badge${unreadAll?'':' zero'}" title="${unreadAll} unread">${unreadAll>999?'999+':unreadAll}</span>`:'');
|
?`<span class="badge${unreadAll?'':' zero'}" title="${unreadAll} unread">${unreadAll>999?'999+':unreadAll}</span>`:'');
|
||||||
el.onclick=()=>{ selectFeed(id); nav(false); };
|
el.onclick=()=>{ selectFeed(id); nav(false); };
|
||||||
|
el.onpointerenter=el.ontouchstart=()=>prefetch(id);
|
||||||
places.appendChild(el);
|
places.appendChild(el);
|
||||||
}
|
}
|
||||||
list.appendChild(places);
|
list.appendChild(places);
|
||||||
|
signalFolder(list,q);
|
||||||
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
|
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
|
||||||
if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return done(); }
|
if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return done(); }
|
||||||
|
|
||||||
@@ -160,6 +169,8 @@ function renderFeeds(){
|
|||||||
(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); };
|
||||||
|
// Fetched as the pointer or a finger reaches it, so the click finds it here (#247).
|
||||||
|
el.onpointerenter=el.ontouchstart=()=>prefetch(f.id);
|
||||||
if(kids) $('.chev',el).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
if(kids) $('.chev',el).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||||
for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); }
|
for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); }
|
||||||
list.appendChild(el);
|
list.appendChild(el);
|
||||||
|
|||||||
139
web/src/items.ts
@@ -1,16 +1,102 @@
|
|||||||
/* ---------------- items ---------------- */
|
/* ---------------- items ---------------- */
|
||||||
let loadSeq=0;
|
let loadSeq=0;
|
||||||
async function loadEntries(append?: boolean){
|
type ListPage={total: number, entries: Entry[]};
|
||||||
// The Directory lists feeds, and Currently Listening draws its own episodes.
|
/// The first page of each list opened or fetched ahead, by what it shows, so a list seen before is
|
||||||
if(!S.feed || VIEWS[S.feed]?.url) return;
|
/// drawn the moment it is asked for and refreshed behind it (#247). The newest few dozen: a page is
|
||||||
const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
|
/// 50 items, about 100 KB as JSON.
|
||||||
|
const lists=new Map<string,ListPage>();
|
||||||
|
const LISTS_KEPT=40;
|
||||||
|
/// Lists on their way, so a click on one already being fetched ahead waits for that answer.
|
||||||
|
const fetching=new Map<string,Promise<ListPage>>();
|
||||||
|
const listKey=(feed: string, filter=S.filter)=>[feed,filter,S.sort.col,S.sort.dir,S.q].join('\n');
|
||||||
|
function listUrl(feed: string, filter=S.filter, offset=0){
|
||||||
|
const p=new URLSearchParams({offset:String(offset),limit:String(LIMIT),filter,sort:S.sort.col,dir:S.sort.dir});
|
||||||
if(S.q) p.set('q',S.q);
|
if(S.q) p.set('q',S.q);
|
||||||
const asked=performance.now(), seq=++loadSeq;
|
const sg=signalId(feed);
|
||||||
const r: {total: number, entries: Entry[]}=await api(S.feed===':all' ? `/api/entries?${p}`
|
return feed===':all' ? `/api/entries?${p}`
|
||||||
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
|
: sg!==null ? `/api/signals/${sg}/entries?${p}`
|
||||||
|
: `/api/feeds/${encodeURIComponent(feed)}/entries?${p}`;
|
||||||
|
}
|
||||||
|
function keepList(key: string, r: ListPage){
|
||||||
|
lists.delete(key); lists.set(key,r);
|
||||||
|
if(lists.size>LISTS_KEPT) lists.delete(lists.keys().next().value!);
|
||||||
|
}
|
||||||
|
/// Fetches a list's first page ahead of a click: when the page has loaded, and when the pointer
|
||||||
|
/// reaches a feed or a filter. Not one kept already or on its way.
|
||||||
|
function prefetch(feed: string, filter=S.filter){
|
||||||
|
// Currently Listening and the Directory draw from their own address.
|
||||||
|
const url=VIEWS[feed]?.url;
|
||||||
|
if(url) return prefetchPage(url);
|
||||||
|
const key=listKey(feed,filter);
|
||||||
|
if(lists.has(key)||fetching.has(key)) return;
|
||||||
|
const got: Promise<ListPage>=api(listUrl(feed,filter));
|
||||||
|
fetching.set(key,got);
|
||||||
|
got.then(r=>keepList(key,r),()=>{}).finally(()=>fetching.delete(key));
|
||||||
|
}
|
||||||
|
/// Other answers a view draws from, kept by address as the lists are (#248): Currently Listening
|
||||||
|
/// and the Directory's catalogue. A request already on its way is shared, not sent twice.
|
||||||
|
const pages=new Map<string,any>();
|
||||||
|
const pageFetching=new Map<string,Promise<any>>();
|
||||||
|
function fetchPage(url: string): Promise<any>{
|
||||||
|
const on=pageFetching.get(url); if(on) return on;
|
||||||
|
const got=api(url).then(r=>{ pages.delete(url); pages.set(url,r); if(pages.size>LISTS_KEPT) pages.delete(pages.keys().next().value!); return r; });
|
||||||
|
pageFetching.set(url,got);
|
||||||
|
got.finally(()=>pageFetching.delete(url)).catch(()=>{});
|
||||||
|
return got;
|
||||||
|
}
|
||||||
|
const prefetchPage=(url: string)=>{ if(!pages.has(url)) fetchPage(url).catch(()=>{}); };
|
||||||
|
|
||||||
|
/// A feed changed (a scan, a read elsewhere): its kept lists, and every one of All Subscriptions,
|
||||||
|
/// are fetched again when next wanted.
|
||||||
|
function forgetLists(feed: string){
|
||||||
|
// A Signal holds items of any feed, and an OPML's list its shows' (#283).
|
||||||
|
const group=S.feeds.find(f=>f.id===feed)?.group;
|
||||||
|
for(const k of [...lists.keys()]) if(k.startsWith(feed+'\n')||k.startsWith(':all\n')||k.startsWith(':signal:')||(group&&k.startsWith(group+'\n'))) lists.delete(k);
|
||||||
|
}
|
||||||
|
/// What is likely to be opened next, fetched two at a time once the page is idle: All Subscriptions
|
||||||
|
/// and the feeds with something unread, most first.
|
||||||
|
function preloadLists(){
|
||||||
|
const want=[':all',':listening',':directory',...[...S.feeds].filter(f=>f.unread>0&&!f.group).sort((a,b)=>b.unread-a.unread).slice(0,20).map(f=>f.id)];
|
||||||
|
const next=()=>{
|
||||||
|
const f=want.shift(); if(!f) return;
|
||||||
|
prefetch(f);
|
||||||
|
const url=VIEWS[f]?.url, on=url ? pageFetching.get(url) : fetching.get(listKey(f));
|
||||||
|
// On to the next when this one is in, failed or not: a failure here is nobody's to see.
|
||||||
|
if(on) on.then(next,next); else next();
|
||||||
|
};
|
||||||
|
const go=()=>{ next(); next(); };
|
||||||
|
'requestIdleCallback' in window ? (window as any).requestIdleCallback(go,{timeout:2000}) : setTimeout(go,300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `quiet` for a refresh nobody asked for (a live update): not a wait anyone sees, so not timed.
|
||||||
|
async function loadEntries(append?: boolean, quiet?: boolean){
|
||||||
|
// The Directory lists feeds, and Currently Listening draws its own episodes.
|
||||||
|
if(!S.feed || VIEWS[S.feed]?.url || S.feed===SIGNALS) return;
|
||||||
|
const feed=S.feed, key=listKey(feed), asked=performance.now(), seq=++loadSeq;
|
||||||
|
// From asking to the list drawn: what someone waits for after a click, under that click's trace
|
||||||
|
// (#246). Once, when the list first shows: from what is kept, at once.
|
||||||
|
let timed=quiet;
|
||||||
|
const shown=()=>{ if(!timed){ timed=true; report('list_shown',{ms:Math.round(performance.now()-asked),feed,filter:S.filter}); } };
|
||||||
|
const kept=append ? undefined : lists.get(key);
|
||||||
|
let spin=0;
|
||||||
|
if(kept){ showList(kept,false,asked); shown(); }
|
||||||
|
// Nothing to show yet: a spinner, if the answer is not back in a blink.
|
||||||
|
else if(!append&&!quiet) spin=setTimeout(()=>{ if(seq===loadSeq) listLoading(); },120);
|
||||||
|
let r: ListPage;
|
||||||
|
try{
|
||||||
|
r=await (!append&&!kept&&fetching.get(key) || api(listUrl(feed,S.filter,S.offset)));
|
||||||
|
}finally{ clearTimeout(spin); }
|
||||||
// Only the latest list counts: marking everything read reloads the All tab, and clicking
|
// Only the latest list counts: marking everything read reloads the All tab, and clicking
|
||||||
// Unread straight after could have that answer land last and replace the Unread one.
|
// Unread straight after could have that answer land last and replace the Unread one.
|
||||||
if(seq!==loadSeq) return;
|
if(seq!==loadSeq) return;
|
||||||
|
if(!append) keepList(key,r);
|
||||||
|
// The kept one was this already: drawn again it only flickers.
|
||||||
|
if(kept&&JSON.stringify(kept)===JSON.stringify(r)) return;
|
||||||
|
showList(r,!!append,asked);
|
||||||
|
shown();
|
||||||
|
}
|
||||||
|
/// A list's page into S and onto the screen.
|
||||||
|
function showList(r: ListPage, append: boolean, asked: number){
|
||||||
S.total=r.total;
|
S.total=r.total;
|
||||||
for(const e of r.entries){
|
for(const e of r.entries){
|
||||||
const w=readWrites.get(readKey(e));
|
const w=readWrites.get(readKey(e));
|
||||||
@@ -30,17 +116,26 @@ async function loadEntries(append?: boolean){
|
|||||||
S.entries = entries;
|
S.entries = entries;
|
||||||
renderEntries();
|
renderEntries();
|
||||||
}
|
}
|
||||||
|
/// A list being waited for: said with a spinner, so the click is seen to have been heard.
|
||||||
|
function listLoading(box=$('#eps')){
|
||||||
|
if(!box) return;
|
||||||
|
box.innerHTML='<p class="listload" role="status">Loading…</p>';
|
||||||
|
}
|
||||||
function renderEntries(){
|
function renderEntries(){
|
||||||
const box=$('#eps'); if(!box) return;
|
const box=$('#eps'); if(!box) return;
|
||||||
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? ''];
|
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? ''], sg=signalOf(S.feed);
|
||||||
const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
// An OPML's own row counts nothing: its shows do.
|
||||||
|
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
|
||||||
|
const unread=kids.length?kids.reduce((n,x)=>n+x.unread,0):f?f.unread:sg?sg.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||||
$('#count').textContent=
|
$('#count').textContent=
|
||||||
`${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
|
`${f?(f.title||f.id):sg?sg.name:v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
|
||||||
const pane=$('#list'), top=pane?pane.scrollTop:0;
|
const pane=$('#list'), top=pane?pane.scrollTop:0;
|
||||||
box.innerHTML='';
|
box.innerHTML='';
|
||||||
if(!S.entries.length){
|
if(!S.entries.length){
|
||||||
// Said only for a search, where the words typed are why; an empty feed or tab speaks for itself.
|
// Said only for a search, where the words typed are why; an empty feed or tab speaks for itself.
|
||||||
if(S.q) box.innerHTML='<p class="empty">Nothing matches that search.</p>';
|
if(S.q) box.innerHTML='<p class="empty">Nothing matches that search.</p>';
|
||||||
|
// A new Signal is empty while Jev reads, which would otherwise look broken.
|
||||||
|
else if(sg&&S.filter==='all') box.innerHTML='<p class="empty">Nothing has come through yet. New items are read as your feeds are checked.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for(const e of S.entries) box.appendChild(epEl(e));
|
for(const e of S.entries) box.appendChild(epEl(e));
|
||||||
@@ -176,10 +271,12 @@ const readWrites=new Map<string, {read: boolean, done?: number}>();
|
|||||||
const readKey=(e: Entry)=>e.feed_id+'\n'+e.guid;
|
const readKey=(e: Entry)=>e.feed_id+'\n'+e.guid;
|
||||||
function setRead(e: Entry,read: boolean){
|
function setRead(e: Entry,read: boolean){
|
||||||
e.read=read;
|
e.read=read;
|
||||||
|
// Kept lists of its feed hold their own copy of it, still as it was.
|
||||||
|
forgetLists(e.feed_id);
|
||||||
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
|
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
|
||||||
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
|
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
|
||||||
{method:'POST',body:JSON.stringify({read})})
|
{method:'POST',body:JSON.stringify({read})})
|
||||||
.then(row=>{ w.done=performance.now(); if(row) patchFeed(row); });
|
.then(row=>{ w.done=performance.now(); if(row) patchFeed(row); if(S.signals.length) refreshSignals(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
|
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
|
||||||
@@ -279,6 +376,12 @@ function showDetail(e: Entry|null){
|
|||||||
box.innerHTML=detailHtml(e);
|
box.innerHTML=detailHtml(e);
|
||||||
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
||||||
$<HTMLButtonElement>('#dback').onclick=()=>showDetail(null);
|
$<HTMLButtonElement>('#dback').onclick=()=>showDetail(null);
|
||||||
|
const view=box.querySelector<HTMLSelectElement>('.tview');
|
||||||
|
if(view) view.onchange=()=>{
|
||||||
|
textView=view.value;
|
||||||
|
try{ localStorage.setItem('ipx.textview',textView); }catch{}
|
||||||
|
box.querySelector('.dbody')!.innerHTML=textOf(e);
|
||||||
|
};
|
||||||
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
|
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
|
||||||
b.onclick=()=>epAction(b.dataset.a!,e,null,b.dataset.enc?Number(b.dataset.enc):undefined));
|
b.onclick=()=>epAction(b.dataset.a!,e,null,b.dataset.enc?Number(b.dataset.enc):undefined));
|
||||||
syncPlayButtons();
|
syncPlayButtons();
|
||||||
@@ -304,6 +407,8 @@ function detailHtml(e: Entry){
|
|||||||
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration),
|
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration),
|
||||||
e.enclosures.some(x=>x.deadair_pending)&&DEAD_PENDING]
|
e.enclosures.some(x=>x.deadair_pending)&&DEAD_PENDING]
|
||||||
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
|
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
|
||||||
|
${e.markdown?`<select class="tview" aria-label="Show the text as">${TEXT_VIEWS.map(([v,label])=>
|
||||||
|
`<option value="${v}"${v===textView?' selected':''}>${label}</option>`).join('')}</select>`:''}
|
||||||
<span class="dacts"><button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
|
<span class="dacts"><button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
|
||||||
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button>
|
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button>
|
||||||
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
|
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
|
||||||
@@ -313,7 +418,17 @@ function detailHtml(e: Entry){
|
|||||||
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}</span>
|
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}</span>
|
||||||
</div>
|
</div>
|
||||||
${narrow?encs:''}
|
${narrow?encs:''}
|
||||||
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
|
<div class="dbody">${textOf(e)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How an item whose feed sends Markdown beside its HTML is shown, kept from one item to the
|
||||||
|
/// next (#285): the publisher's HTML, or the Markdown made HTML.
|
||||||
|
const TEXT_VIEWS=[['html','HTML'],['markdown','Markdown']];
|
||||||
|
let textView=(()=>{ try{ return localStorage.getItem('ipx.textview'); }catch{ return null; } })()||'html';
|
||||||
|
function textOf(e: Entry){
|
||||||
|
// Cleaned server-side with ammonia, as the description is.
|
||||||
|
if(e.markdown&&textView==='markdown'&&e.markdown_html) return e.markdown_html;
|
||||||
|
return (e.description&&e.description.trim())||'<em>No show notes.</em>';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
||||||
|
|||||||
@@ -164,13 +164,13 @@ $<HTMLInputElement>('#seek').oninput=(e: Event)=>{const d=audio.duration;if(d)au
|
|||||||
$<HTMLSelectElement>('#rate').onchange=(e: Event)=>{audio.playbackRate=+(e.target as HTMLSelectElement).value;localStorage.setItem('ipx.rate',(e.target as HTMLSelectElement).value)};
|
$<HTMLSelectElement>('#rate').onchange=(e: Event)=>{audio.playbackRate=+(e.target as HTMLSelectElement).value;localStorage.setItem('ipx.rate',(e.target as HTMLSelectElement).value)};
|
||||||
$<HTMLInputElement>('#vol').oninput=(e: Event)=>{audio.volume=Number((e.target as HTMLInputElement).value)/100;localStorage.setItem('ipx.vol',(e.target as HTMLInputElement).value)};
|
$<HTMLInputElement>('#vol').oninput=(e: Event)=>{audio.volume=Number((e.target as HTMLInputElement).value)/100;localStorage.setItem('ipx.vol',(e.target as HTMLInputElement).value)};
|
||||||
/// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the
|
/// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the
|
||||||
/// icon's shape and aria-pressed, never a colour.
|
/// icon's shape (the silence still in it, or closed up) and aria-pressed, never a colour.
|
||||||
function paintDead(){
|
function paintDead(){
|
||||||
const b=$<HTMLButtonElement>('#pdead'), f=S.feeds.find(x=>x.id===player.feed);
|
const b=$<HTMLButtonElement>('#pdead'), f=S.feeds.find(x=>x.id===player.feed);
|
||||||
b.hidden=!f||document.body.classList.contains('has-video');
|
b.hidden=!f||document.body.classList.contains('has-video');
|
||||||
if(!f||b.hidden) return;
|
if(!f||b.hidden) return;
|
||||||
b.setAttribute('aria-pressed',String(!!f.deadair));
|
b.setAttribute('aria-pressed',String(!!f.deadair));
|
||||||
b.innerHTML=f.deadair?ICON.deadairOn:ICON.deadair;
|
b.innerHTML=tile(f.deadair?'deadair-on':'deadair-off');
|
||||||
b.title=f.deadair?'DeadAir is cutting the dead air from this show':'DeadAir: cut the dead air from this show';
|
b.title=f.deadair?'DeadAir is cutting the dead air from this show':'DeadAir: cut the dead air from this show';
|
||||||
}
|
}
|
||||||
$<HTMLButtonElement>('#pdead').onclick=async()=>{
|
$<HTMLButtonElement>('#pdead').onclick=async()=>{
|
||||||
|
|||||||
293
web/src/signals.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
/* ---------------- signals ---------------- */
|
||||||
|
/// Signals (#254): feeds you make from your own subscriptions, holding the items TypeSafe's Jev
|
||||||
|
/// says are about the keywords you gave. A Signal is a place in the sidebar, `:signal:<id>`.
|
||||||
|
|
||||||
|
/// The Signals' counts again, which nothing else brings: reading an item, here or in its feed,
|
||||||
|
/// and Jev picking one out both change them (#258). Once a burst has passed.
|
||||||
|
const refreshSignals=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
|
||||||
|
api('/api/signals').then((r: Signal[])=>{ S.signals=r; renderFeeds(); }).catch(()=>{});
|
||||||
|
},400); }; })();
|
||||||
|
|
||||||
|
/// The Signal's id in a place's id, or null for anything else.
|
||||||
|
function signalId(view: string|null){
|
||||||
|
const m=/^:signal:(\d+)$/.exec(view||'');
|
||||||
|
return m ? Number(m[1]) : null;
|
||||||
|
}
|
||||||
|
function signalOf(view: string|null){
|
||||||
|
const id=signalId(view);
|
||||||
|
return id===null ? undefined : S.signals.find(s=>s.id===id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether you have Signals (#271): everyone does on the server's key; on your own, once you have
|
||||||
|
/// turned them on in Settings with a key TypeSafe took.
|
||||||
|
const signalsOn=()=>S.me?.signals==='server'||!!S.me?.typesafe_key_set;
|
||||||
|
|
||||||
|
/// The Signals folder: first in the feed list, under All Subscriptions, open or closed as an OPML's
|
||||||
|
/// is (#255, #256). As rows under the places, a long list of them pushed the feeds down.
|
||||||
|
const SIGNALS=':signals';
|
||||||
|
const sigArt=()=>`<span class="fart"><div class="art">${tile('signals')}</div></span>`;
|
||||||
|
function feedRow(id: string, cls: string, html: string, open: ()=>void){
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className='feed '+cls+(S.feed===id?' sel':'');
|
||||||
|
el.tabIndex=0; el.dataset.id=id; el.innerHTML=html;
|
||||||
|
el.onclick=()=>{ open(); nav(false); };
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
/// A Signal's icon, drawn from /icons as a mask so it takes the theme's colour, or its initials.
|
||||||
|
/// The name goes into an address and a style: only a style and a plain name are drawn (the server
|
||||||
|
/// takes nothing else either).
|
||||||
|
const ICON_ID=/^(solid|regular|brands)\/[a-z0-9-]{1,64}$/;
|
||||||
|
const iconHTML=(icon: string)=>`<span class="ficon" style="--icon:url('/icons/${icon}.svg')"></span>`;
|
||||||
|
/// Every Signal in the accent tint the folder has, icon or initials: tinted by a hash of its name,
|
||||||
|
/// as a feed without art is, one came out a colour of its own for no reason anyone could see (#262).
|
||||||
|
function signalTile(sg: {name: string, icon: string|null}){
|
||||||
|
const inner=sg.icon&&ICON_ID.test(sg.icon) ? iconHTML(sg.icon) : esc(initials(sg.name));
|
||||||
|
return `<div class="art ini sig" style="--tint:var(--accent)">${inner}</div>`;
|
||||||
|
}
|
||||||
|
const badgeHTML=(n: number)=>`<span class="badge${n?'':' zero'}" title="${n} unread">${n>999?'999+':n}</span>`;
|
||||||
|
function signalFolder(list: HTMLElement, q: string){
|
||||||
|
if(!signalsOn()) return;
|
||||||
|
const mine=S.signals.filter(s=>!q||s.name.toLowerCase().includes(q));
|
||||||
|
if(q&&!mine.length) return;
|
||||||
|
const open=expanded.has(SIGNALS)||!!q;
|
||||||
|
const n=S.signals.length;
|
||||||
|
list.appendChild(feedRow(SIGNALS,'sigs',
|
||||||
|
`<button class="chev" aria-expanded="${open}" title="Show or hide your signals" aria-label="Show or hide your signals">${ICON.caret}</button>`+
|
||||||
|
sigArt()+`<div class="txt"><b>Signals</b><small>${n?plural(n,'signal'):'What matters, out of the noise'}</small></div>`+
|
||||||
|
// No + of its own (#265): a row like any folder's. New signal is on the folder's page.
|
||||||
|
badgeHTML(S.signals.reduce((k,s)=>k+s.unread,0)),
|
||||||
|
()=>selectFeed(SIGNALS)));
|
||||||
|
const row=list.lastElementChild!;
|
||||||
|
$('.chev',row).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(SIGNALS); };
|
||||||
|
if(!open) return;
|
||||||
|
for(const sg of mine){
|
||||||
|
const el=feedRow(':signal:'+sg.id,'child',
|
||||||
|
`<span class="fart">${signalTile(sg)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${signalCounts(sg)}</small></div>`+badgeHTML(sg.unread),
|
||||||
|
()=>selectFeed(':signal:'+sg.id));
|
||||||
|
el.onpointerenter=el.ontouchstart=()=>prefetch(':signal:'+sg.id);
|
||||||
|
list.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The folder's page: every Signal, as an OPML's page lists its feeds.
|
||||||
|
function renderSignals(){
|
||||||
|
const unread=S.signals.reduce((k,s)=>k+s.unread,0);
|
||||||
|
$('#count').textContent=`Signals: ${plural(S.signals.length,'signal')}, ${unread} unread`;
|
||||||
|
$<HTMLButtonElement>('#tbRemove').disabled=true;
|
||||||
|
syncTools(null);
|
||||||
|
const box=$('#content');
|
||||||
|
box.classList.add('plain');
|
||||||
|
box.innerHTML=`
|
||||||
|
<div class="fhead slim">
|
||||||
|
<div class="art">${tile('signals')}</div>
|
||||||
|
<div class="meta">
|
||||||
|
<h2>Signals</h2>
|
||||||
|
<div class="sub">Each one picks what matters out of the noise of your ${plural(S.feeds.length,'feed')} · ${unread} unread</div>
|
||||||
|
</div>
|
||||||
|
<div class="acts">
|
||||||
|
<button class="btn ico" data-a="new" title="New signal" aria-label="New signal">${ICON.plus}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="childlist" id="siglist"></div>`;
|
||||||
|
$<HTMLButtonElement>('#content .acts [data-a="new"]').onclick=()=>signalModal();
|
||||||
|
const list=$('#siglist');
|
||||||
|
if(!S.signals.length){
|
||||||
|
list.innerHTML='<p class="empty">No signals yet. Make one to pull a subject out of every feed you subscribe to.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for(const sg of S.signals){
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className='childrow';
|
||||||
|
el.innerHTML=signalTile(sg)+`<div class="txt"><b>${esc(sg.name)}</b>`+
|
||||||
|
`<small class="meta">${signalCounts(sg)}</small></div><span class="badge${sg.unread?'':' zero'}">${sg.unread}</span>`;
|
||||||
|
el.onclick=()=>selectFeed(':signal:'+sg.id);
|
||||||
|
list.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function signalHead(sg: Signal){
|
||||||
|
const nokey=S.me&&!signalsOn();
|
||||||
|
return `
|
||||||
|
<div class="fhead slim">
|
||||||
|
${signalTile(sg)}
|
||||||
|
<div class="meta">
|
||||||
|
<h2>${esc(sg.name)}</h2>
|
||||||
|
<div class="sub">Picked out of the noise of your ${plural(S.feeds.length,'feed')}: ${esc(sg.description||sg.keywords.join(', '))} · ${sg.unread} unread</div>
|
||||||
|
${nokey?`<div class="sub" style="color:var(--warn)">No TypeSafe key yet, so nothing is being picked out. ${KEY_LINKS}</div>`:''}
|
||||||
|
</div>
|
||||||
|
<div class="acts">
|
||||||
|
<button class="btn ico" data-a="settings" title="Edit this signal" aria-label="Edit this signal">${ICON.settings}</button>
|
||||||
|
<button class="btn ico danger" data-a="rm" title="Delete this signal" aria-label="Delete this signal">${ICON.trash}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signalAction(a: string, sg: Signal){
|
||||||
|
if(a==='settings') signalModal(sg);
|
||||||
|
if(a==='rm'){
|
||||||
|
if(!confirm(`Delete the signal ${sg.name}? The items stay in their own feeds.`)) return;
|
||||||
|
try{
|
||||||
|
await api(`/api/signals/${sg.id}`,{method:'DELETE'});
|
||||||
|
toast('Deleted'); await loadFeeds(true); selectFeed(':all');
|
||||||
|
}catch(e){ toast(why(e),true); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Making a Signal, or changing one.
|
||||||
|
function signalModal(sg?: Signal){
|
||||||
|
const nokey=S.me&&!signalsOn();
|
||||||
|
openModal(`<h3>${sg?'Edit signal':'New signal'}</h3>
|
||||||
|
<div class="field"><label for="sgname">Name</label>
|
||||||
|
<div class="inline"><button type="button" class="iconpick" id="sgicon" title="Choose an icon" aria-label="Choose an icon"
|
||||||
|
aria-expanded="false"></button>
|
||||||
|
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
|
||||||
|
<div id="sgpicker" hidden>
|
||||||
|
<div class="inline">${searchBox('<input type="search" id="sgiconq" placeholder="Search icons: robot, car, music…" autocomplete="off">')}
|
||||||
|
<button type="button" class="dlink" id="sgjev" title="Pick an icon for me">Auto</button>
|
||||||
|
<button type="button" class="dlink" id="sgnoicon">Initials</button></div>
|
||||||
|
<div class="icongrid" id="sgicons" role="listbox" aria-label="Icons"></div>
|
||||||
|
</div></div>
|
||||||
|
<div class="field"><label for="sgkwin">Listen for</label>
|
||||||
|
<div class="chips" id="sgkw">${chipsHTML(sg?.keywords||[],'sgkwin')}</div></div>
|
||||||
|
<div class="field"><label for="sgdesc">In your own words (optional)</label>
|
||||||
|
<input type="text" id="sgdesc" value="${esc(sg?.description||'')}" placeholder="AI tools for writing software: coding agents, vibe coding">
|
||||||
|
<span class="hint">Every item in the feeds you subscribe to is read, the last ${plural(S.me?.signal_days??7,'day')} of them
|
||||||
|
and each new one, and the ones about this subject are pulled out: its name, these words and
|
||||||
|
your sentence together. The rest stays noise, in its own feed.${S.me?.signals==='server'?'':' Each item is one call on your TypeSafe key.'}${sg?' A change here reads again what was picked out, and listens for it in new items; what was passed over stays passed over.':''}</span></div>
|
||||||
|
${nokey?`<p class="hint" style="color:var(--warn)">Without a TypeSafe key, nothing is picked out. ${KEY_LINKS}</p>`:''}
|
||||||
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
|
<button class="btn ico primary" id="sgsave" title="${sg?'Save':'Make signal'}" aria-label="${sg?'Save':'Make signal'}">${sg?ICON.check:ICON.plus}</button></div>`);
|
||||||
|
wireChips($('#sgkw'));
|
||||||
|
let icon=sg?.icon??null;
|
||||||
|
const drawPick=()=>{ $('#sgicon').innerHTML=signalTile({name:$<HTMLInputElement>('#sgname').value||'?',icon}); };
|
||||||
|
drawPick();
|
||||||
|
$<HTMLInputElement>('#sgname').oninput=drawPick;
|
||||||
|
const grid=$('#sgicons');
|
||||||
|
const drawGrid=async()=>{
|
||||||
|
const all=await iconList();
|
||||||
|
const shown=iconMatches(all,$<HTMLInputElement>('#sgiconq').value,ICON_CELLS);
|
||||||
|
grid.innerHTML=shown.length ? shown.map(([id,label])=>`<button type="button" class="iconcell" role="option" data-ic="${id}"
|
||||||
|
aria-selected="${id===icon}" title="${esc(label)}" aria-label="${esc(label)}">${iconHTML(id)}</button>`).join('')
|
||||||
|
: '<p class="hint">No icon matches that.</p>';
|
||||||
|
};
|
||||||
|
$<HTMLButtonElement>('#sgicon').onclick=()=>{
|
||||||
|
const p=$('#sgpicker'), open=p.hidden;
|
||||||
|
p.hidden=!open; $('#sgicon').setAttribute('aria-expanded',String(open));
|
||||||
|
if(open){ drawGrid().catch(e=>toast(why(e),true)); $<HTMLInputElement>('#sgiconq').focus(); }
|
||||||
|
};
|
||||||
|
$<HTMLInputElement>('#sgiconq').oninput=()=>{ drawGrid().catch(()=>{}); };
|
||||||
|
grid.onclick=(e: MouseEvent)=>{
|
||||||
|
const b=(e.target as Element).closest<HTMLElement>('[data-ic]'); if(!b) return;
|
||||||
|
icon=b.dataset.ic!; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false');
|
||||||
|
};
|
||||||
|
$<HTMLButtonElement>('#sgnoicon').onclick=()=>{ icon=null; drawPick(); $('#sgpicker').hidden=true; };
|
||||||
|
$<HTMLButtonElement>('#sgjev').onclick=async()=>{
|
||||||
|
// A spinner while it is picked: what picks it is nobody's concern.
|
||||||
|
const b=$<HTMLButtonElement>('#sgjev'); b.disabled=true; b.classList.add('busy'); b.setAttribute('aria-busy','true');
|
||||||
|
try{
|
||||||
|
const got=await jevIcon($<HTMLInputElement>('#sgname').value.trim(),chipWords($('#sgkw')).filter((w): w is string=>!!w),
|
||||||
|
$<HTMLInputElement>('#sgdesc').value.trim()||null);
|
||||||
|
if(got){ icon=got; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false'); }
|
||||||
|
else toast('No icon matches those words');
|
||||||
|
}catch(e){ toast(why(e),true); }
|
||||||
|
finally{ b.disabled=false; b.classList.remove('busy'); b.removeAttribute('aria-busy'); }
|
||||||
|
};
|
||||||
|
$<HTMLInputElement>('#sgname').focus();
|
||||||
|
$<HTMLButtonElement>('#sgsave').onclick=async()=>{
|
||||||
|
const name=$<HTMLInputElement>('#sgname').value.trim(), keywords=chipWords($('#sgkw')).filter((w): w is string=>!!w);
|
||||||
|
if(!name){ $<HTMLInputElement>('#sgname').focus(); return; }
|
||||||
|
if(!keywords.length){ $<HTMLInputElement>('#sgkwin').focus(); return; }
|
||||||
|
const description=$<HTMLInputElement>('#sgdesc').value.trim()||null;
|
||||||
|
// Only what changed: a new name, words or sentence has Jev read everything again, and saving
|
||||||
|
// a new icon alone did that too while the dialog sent them all back each time.
|
||||||
|
const body=JSON.stringify(sg ? signalChanges(sg,{name,keywords,description,icon}) : {name,keywords,description,icon});
|
||||||
|
try{
|
||||||
|
const r=sg ? await api(`/api/signals/${sg.id}`,{method:'PATCH',body})
|
||||||
|
: await api('/api/signals',{method:'POST',body});
|
||||||
|
// A new one made without an icon gets Jev's pick, behind, so making it does not wait (#263).
|
||||||
|
if(!sg&&!icon) jevIcon(name,keywords,description)
|
||||||
|
.then(got=>{ if(got) return api(`/api/signals/${r.id}`,{method:'PATCH',body:JSON.stringify({icon:got})}).then(refreshSignals); })
|
||||||
|
.catch(()=>{});
|
||||||
|
closeModal(); toast(sg?'Saved. Everything is read again':'Listening. What comes through shows up here');
|
||||||
|
// Open, so the new one is there to see.
|
||||||
|
if(!expanded.has(SIGNALS)) toggleGroup(SIGNALS);
|
||||||
|
await loadFeeds(true); selectFeed(':signal:'+(sg?sg.id:r.id));
|
||||||
|
}catch(e){ toast(why(e),true); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What differs between a Signal and the dialog's fields, as PATCH takes it.
|
||||||
|
function signalChanges(sg: Signal, now: {name: string, keywords: string[], description: string|null, icon: string|null}){
|
||||||
|
const out: Record<string, unknown>={};
|
||||||
|
if(now.name!==sg.name) out.name=now.name;
|
||||||
|
if(now.keywords.join('\n')!==sg.keywords.join('\n')) out.keywords=now.keywords;
|
||||||
|
if(now.description!==sg.description) out.description=now.description;
|
||||||
|
if(now.icon!==sg.icon) out.icon=now.icon;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Font Awesome Free's index, `[style/name, label, terms]`, fetched once, when the picker first
|
||||||
|
/// opens: 2,883 icons are about 90 KB compressed, which the page has no other use for.
|
||||||
|
const ICON_CELLS=120;
|
||||||
|
let icons: Promise<[string,string,string][]>|null=null;
|
||||||
|
function iconList(){
|
||||||
|
if(!icons) icons=api('__ICONS__').catch((e: unknown)=>{ icons=null; throw e; });
|
||||||
|
return icons!;
|
||||||
|
}
|
||||||
|
/// The icons with a word starting with each word typed, in their name, label or search terms. The
|
||||||
|
/// first word typed ranks them: the icon named it, then one with it as a word of its name, then as
|
||||||
|
/// a search term, then the rest. Matching anywhere in a word, "car" filled the grid with caret and
|
||||||
|
/// cart (#261). At most `max`; nothing typed is the first `max`.
|
||||||
|
function iconMatches(all: [string,string,string][], q: string, max: number){
|
||||||
|
const words=q.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
if(!words.length) return all.slice(0,max);
|
||||||
|
const split=(s: string)=>s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
||||||
|
const rank=([id,label,terms]: [string,string,string])=>{
|
||||||
|
const name=id.slice(id.indexOf('/')+1), own=split(name), hay=[...own,...split(label),...split(terms)];
|
||||||
|
if(!words.every(w=>hay.some(h=>h.startsWith(w)))) return -1;
|
||||||
|
const w=words[0];
|
||||||
|
return name===w ? 0 : own.includes(w) ? 1 : hay.includes(w) ? 2 : 3;
|
||||||
|
};
|
||||||
|
return all.map(i=>[rank(i),i] as const).filter(([r])=>r>=0).sort((a,b)=>a[0]-b[0]).slice(0,max).map(([,i])=>i);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "12 items · 3 unread", as a feed's row counts its own.
|
||||||
|
const signalCounts=(sg: Signal)=>`${plural(sg.items,'item')} · ${sg.unread} unread`;
|
||||||
|
|
||||||
|
/// The icons likeliest for a Signal: the best few for each word of its name, words and sentence,
|
||||||
|
/// at most `max`. Jev picks from these, since it chooses among options and cannot name one.
|
||||||
|
const ICON_STOP=new Set(['the','and','for','not','with','about','their','own','from','into','that','this','your']);
|
||||||
|
function iconCandidates(all: [string,string,string][], name: string, keywords: string[], description: string|null, max=40){
|
||||||
|
const words=[name,...keywords,description||''].join(' ').toLowerCase().split(/[^a-z0-9]+/).filter(w=>w.length>1&&!ICON_STOP.has(w));
|
||||||
|
const out=new Map<string,string>();
|
||||||
|
// A brand's icon stands for that brand, so it is offered only when its name is one of the
|
||||||
|
// Signal's words: by its search terms, "disney" brought the Mandalorian, a Disney+ show (#264).
|
||||||
|
const brandFor=(id: string, w: string)=>!id.startsWith('brands/')||id.slice(7).split('-').includes(w);
|
||||||
|
for(const w of [...new Set(words)])
|
||||||
|
for(const [id,label,terms] of iconMatches(all.filter(([id])=>brandFor(id,w)),w,6))
|
||||||
|
if(out.size<max) out.set(id,`${label}: ${terms.split(' ').slice(0,8).join(', ')}`);
|
||||||
|
return [...out];
|
||||||
|
}
|
||||||
|
/// Jev's pick of an icon, on the server's TypeSafe key, or null with nothing to choose from.
|
||||||
|
async function jevIcon(name: string, keywords: string[], description: string|null): Promise<string|null>{
|
||||||
|
const candidates=iconCandidates(await iconList(),name,keywords,description);
|
||||||
|
if(!candidates.length) return null;
|
||||||
|
const r=await api('/api/signals/icon',{method:'POST',body:JSON.stringify({name,keywords,description,candidates})});
|
||||||
|
return r.icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where to get a TypeSafe key and where it goes, wherever a Signal says it has none: one click to
|
||||||
|
/// each, rather than "in Settings" left to be found.
|
||||||
|
const KEY_LINKS=`<a href="https://typesafe.ai" target="_blank" rel="noopener">Get one at typesafe.ai</a>, then
|
||||||
|
<button type="button" class="dlink" data-keylink>add it in Settings</button>.`;
|
||||||
|
/// Settings, open at the key's field.
|
||||||
|
async function keySetting(){
|
||||||
|
closeModal();
|
||||||
|
await prefsModal();
|
||||||
|
$<HTMLInputElement>('#ssigon').checked=true; $('#ssigkey').hidden=false;
|
||||||
|
const k=$<HTMLInputElement>('#stskey'); k.scrollIntoView({block:'center'}); k.focus();
|
||||||
|
}
|
||||||
|
document.addEventListener('click',(e: MouseEvent)=>{
|
||||||
|
if(!(e.target as Element).closest?.('[data-keylink]')) return;
|
||||||
|
e.preventDefault(); keySetting().catch(err=>toast(why(err),true));
|
||||||
|
});
|
||||||
@@ -35,6 +35,12 @@ interface Feed {
|
|||||||
unread: number;
|
unread: number;
|
||||||
subscribers: number;
|
subscribers: number;
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
|
/** From an OPML, and yours to keep should it drop the feed (#280). */
|
||||||
|
kept: boolean;
|
||||||
|
/** Left out of All Subscriptions by its own setting; a show is also when its OPML is (#288). */
|
||||||
|
hide_from_all: boolean;
|
||||||
|
/** The same for Signals (#289). */
|
||||||
|
hide_from_signals: boolean;
|
||||||
private: boolean;
|
private: boolean;
|
||||||
private_by_address: boolean;
|
private_by_address: boolean;
|
||||||
}
|
}
|
||||||
@@ -47,6 +53,9 @@ interface Entry {
|
|||||||
link: string | null;
|
link: string | null;
|
||||||
published: number | null;
|
published: number | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
/** The item's Markdown as written, when its feed sends it beside the HTML, and made HTML (#285). */
|
||||||
|
markdown: string | null;
|
||||||
|
markdown_html: string | null;
|
||||||
read: boolean;
|
read: boolean;
|
||||||
flagged: boolean;
|
flagged: boolean;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
@@ -82,6 +91,26 @@ interface Me {
|
|||||||
deadair_saved: number;
|
deadair_saved: number;
|
||||||
theme: {name: string, mode: string} | null;
|
theme: {name: string, mode: string} | null;
|
||||||
version: string;
|
version: string;
|
||||||
|
/// A TypeSafe key is saved for Signals; the key itself never comes back.
|
||||||
|
typesafe_key_set: boolean;
|
||||||
|
/// Whose TypeSafe key Signals are judged on (#271): the server's, for everyone, or each person's own.
|
||||||
|
signals: 'server' | 'own';
|
||||||
|
/// How many days back a new Signal reads (#276), an admin's setting.
|
||||||
|
signal_days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Signal in your sidebar, `/api/signals` (db.rs SignalRow).
|
||||||
|
interface Signal {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
keywords: string[];
|
||||||
|
/// What its owner means by it, in their words (#259).
|
||||||
|
description: string | null;
|
||||||
|
/// A Font Awesome Free icon, `solid/robot`, in place of its initials (#261).
|
||||||
|
icon: string | null;
|
||||||
|
/// The items it holds, read or not, and of those the unread.
|
||||||
|
items: number;
|
||||||
|
unread: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A feed as the Directory lists it (web.rs PopularRow): never its address.
|
/// A feed as the Directory lists it (web.rs PopularRow): never its address.
|
||||||
@@ -101,6 +130,7 @@ interface DirFeed {
|
|||||||
/// The page's own state (util.ts `S`).
|
/// The page's own state (util.ts `S`).
|
||||||
interface State {
|
interface State {
|
||||||
feeds: Feed[];
|
feeds: Feed[];
|
||||||
|
signals: Signal[];
|
||||||
/// The feed or place open: a feed's id, or a view such as ':all'.
|
/// The feed or place open: a feed's id, or a view such as ':all'.
|
||||||
feed: string | null;
|
feed: string | null;
|
||||||
entries: Entry[];
|
entries: Entry[];
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const esc = (s: string|null|undefined) => (s??'').replace(/[&<>"']/g,c=>ESCAPED[
|
|||||||
// ones used, so there is no font to download and nothing is fetched from anyone else. Each takes
|
// ones used, so there is no font to download and nothing is fetched from anyone else. Each takes
|
||||||
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
|
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
|
||||||
const fa=(box: string,body: string)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
|
const fa=(box: string,body: string)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
|
||||||
|
/// An app-icon tile (web/signals.svg, deadair-on.svg, ...) in the mode's own variant, as the logo is.
|
||||||
|
const tile=(name: string)=>`<img class="tile onlight" src="/${name}.svg" alt=""><img class="tile ondark" src="/${name}-dark.svg" alt="">`;
|
||||||
const ICON: Record<string,string>={
|
const ICON: Record<string,string>={
|
||||||
share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes
|
share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes
|
||||||
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
|
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
|
||||||
@@ -24,10 +26,6 @@ const ICON: Record<string,string>={
|
|||||||
// for the outline's stroke, so the two draw the same size.
|
// for the outline's stroke, so the two draw the same size.
|
||||||
pin:fa('-18 -18 420 548','<path fill="none" stroke="currentColor" stroke-width="36" stroke-linejoin="round" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack, outlined
|
pin:fa('-18 -18 420 548','<path fill="none" stroke="currentColor" stroke-width="36" stroke-linejoin="round" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack, outlined
|
||||||
pinOn:fa('-18 -18 420 548','<path fill="currentColor" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack
|
pinOn:fa('-18 -18 420 548','<path fill="currentColor" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack
|
||||||
// DeadAir (#171): a waveform. Off for this show, outlined with the silence still in its
|
|
||||||
// middle; on, solid with the gap closed up. Padded like pin for the outline's stroke.
|
|
||||||
deadair:fa('-16 -16 544 544','<g fill="none" stroke="currentColor" stroke-width="24"><rect x="14" y="200" width="44" height="112" rx="22"/><rect x="84" y="136" width="44" height="240" rx="22"/><rect x="154" y="96" width="44" height="320" rx="22"/><rect x="314" y="96" width="44" height="320" rx="22"/><rect x="384" y="136" width="44" height="240" rx="22"/><rect x="454" y="200" width="44" height="112" rx="22"/></g>'),
|
|
||||||
deadairOn:fa('-16 -16 544 544','<g fill="currentColor"><rect x="59" y="200" width="44" height="112" rx="22"/><rect x="129" y="136" width="44" height="240" rx="22"/><rect x="199" y="96" width="44" height="320" rx="22"/><rect x="269" y="96" width="44" height="320" rx="22"/><rect x="339" y="136" width="44" height="240" rx="22"/><rect x="409" y="200" width="44" height="112" rx="22"/></g>'),
|
|
||||||
scan:fa('0 0 512 512','<path fill="currentColor" d="M65.9 228.5c13.3-93 93.4-164.5 190.1-164.5 53 0 101 21.5 135.8 56.2 .2 .2 .4 .4 .6 .6l7.6 7.2-47.9 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 53.4-11.3-10.7C390.5 28.6 326.5 0 256 0 127 0 20.3 95.4 2.6 219.5 .1 237 12.2 253.2 29.7 255.7s33.7-9.7 36.2-27.1zm443.5 64c2.5-17.5-9.7-33.7-27.1-36.2s-33.7 9.7-36.2 27.1c-13.3 93-93.4 164.5-190.1 164.5-53 0-101-21.5-135.8-56.2-.2-.2-.4-.4-.6-.6l-7.6-7.2 47.9 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 320c-8.5 0-16.7 3.4-22.7 9.5S-.1 343.7 0 352.3l1 127c.1 17.7 14.6 31.9 32.3 31.7S65.2 496.4 65 478.7l-.4-51.5 10.7 10.1c46.3 46.1 110.2 74.7 180.7 74.7 129 0 235.7-95.4 253.4-219.5z"/>'), // solid/arrows-rotate
|
scan:fa('0 0 512 512','<path fill="currentColor" d="M65.9 228.5c13.3-93 93.4-164.5 190.1-164.5 53 0 101 21.5 135.8 56.2 .2 .2 .4 .4 .6 .6l7.6 7.2-47.9 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 53.4-11.3-10.7C390.5 28.6 326.5 0 256 0 127 0 20.3 95.4 2.6 219.5 .1 237 12.2 253.2 29.7 255.7s33.7-9.7 36.2-27.1zm443.5 64c2.5-17.5-9.7-33.7-27.1-36.2s-33.7 9.7-36.2 27.1c-13.3 93-93.4 164.5-190.1 164.5-53 0-101-21.5-135.8-56.2-.2-.2-.4-.4-.6-.6l-7.6-7.2 47.9 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 320c-8.5 0-16.7 3.4-22.7 9.5S-.1 343.7 0 352.3l1 127c.1 17.7 14.6 31.9 32.3 31.7S65.2 496.4 65 478.7l-.4-51.5 10.7 10.1c46.3 46.1 110.2 74.7 180.7 74.7 129 0 235.7-95.4 253.4-219.5z"/>'), // solid/arrows-rotate
|
||||||
download:fa('0 0 448 512','<path fill="currentColor" d="M256 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 210.7-41.4-41.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c12.5 12.5 32.8 12.5 45.3 0l96-96c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 242.7 256 32zM64 320c-35.3 0-64 28.7-64 64l0 32c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-32c0-35.3-28.7-64-64-64l-46.9 0-56.6 56.6c-31.2 31.2-81.9 31.2-113.1 0L110.9 320 64 320zm304 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/>'), // solid/download
|
download:fa('0 0 448 512','<path fill="currentColor" d="M256 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 210.7-41.4-41.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c12.5 12.5 32.8 12.5 45.3 0l96-96c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 242.7 256 32zM64 320c-35.3 0-64 28.7-64 64l0 32c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-32c0-35.3-28.7-64-64-64l-46.9 0-56.6 56.6c-31.2 31.2-81.9 31.2-113.1 0L110.9 320 64 320zm304 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/>'), // solid/download
|
||||||
save:fa('0 0 448 512','<path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>'), // solid/floppy-disk
|
save:fa('0 0 448 512','<path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>'), // solid/floppy-disk
|
||||||
@@ -63,6 +61,20 @@ const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
|
|||||||
// The page's own buttons name their icon; this draws it in, ahead of any label they carry.
|
// The page's own buttons name their icon; this draws it in, ahead of any label they carry.
|
||||||
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon!]);
|
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon!]);
|
||||||
|
|
||||||
|
/// A search box with an x at its right end that clears it, shown once something is typed (#291).
|
||||||
|
const searchBox=(input: string, cls='')=>
|
||||||
|
`<span class="clearable ${cls}">${input}<button type="button" class="clearx" title="Clear" aria-label="Clear">${ICON.close}</button></span>`;
|
||||||
|
// Every box's x, wherever it is drawn: cleared as if the typing were deleted, so the box's own input
|
||||||
|
// handler runs and its list comes back whole.
|
||||||
|
document.addEventListener('click',(e: MouseEvent)=>{
|
||||||
|
const x=(e.target as Element).closest?.('.clearx');
|
||||||
|
const box=x?.parentElement?.querySelector('input');
|
||||||
|
if(!box) return;
|
||||||
|
box.value='';
|
||||||
|
box.dispatchEvent(new Event('input',{bubbles:true}));
|
||||||
|
box.focus();
|
||||||
|
});
|
||||||
|
|
||||||
/// An episode whose DeadAir copy is still being made: it plays as it is until then, and the line
|
/// An episode whose DeadAir copy is still being made: it plays as it is until then, and the line
|
||||||
/// becomes how much shorter it is once the copy is ready (the dead_air event).
|
/// becomes how much shorter it is once the copy is ready (the dead_air event).
|
||||||
const DEAD_PENDING='<span class="dapend" title="DeadAir is still working on this episode. Until it is done, it plays at its full length.">Cutting dead air…</span>';
|
const DEAD_PENDING='<span class="dapend" title="DeadAir is still working on this episode. Until it is done, it plays at its full length.">Cutting dead air…</span>';
|
||||||
@@ -93,8 +105,9 @@ function traceparent(){
|
|||||||
/// a frame.
|
/// a frame.
|
||||||
const reported=new Map<string,number>();
|
const reported=new Map<string,number>();
|
||||||
function report(ev: string, o: Record<string,any>={}){
|
function report(ev: string, o: Record<string,any>={}){
|
||||||
|
// A timing comes of something someone did, not a loop, so every one is sent.
|
||||||
const key=ev+(o.msg||''), now=Date.now();
|
const key=ev+(o.msg||''), now=Date.now();
|
||||||
if(o.msg==='signed out' || now-(reported.get(key)||0)<60_000) return;
|
if(o.msg==='signed out' || (o.ms==null && now-(reported.get(key)||0)<60_000)) return;
|
||||||
reported.set(key,now);
|
reported.set(key,now);
|
||||||
fetch('/api/client',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json',traceparent:traceparent()},
|
fetch('/api/client',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json',traceparent:traceparent()},
|
||||||
body:JSON.stringify({ev,...o})}).catch(()=>{});
|
body:JSON.stringify({ev,...o})}).catch(()=>{});
|
||||||
@@ -217,7 +230,7 @@ function nav(on: boolean){ $('#sidebar').classList.toggle('open',on); $('#scrim'
|
|||||||
|
|
||||||
/* ---------------- state ---------------- */
|
/* ---------------- state ---------------- */
|
||||||
const S: State = {
|
const S: State = {
|
||||||
feeds:[],
|
feeds:[], signals:[],
|
||||||
// Which feed (or place) and which tab were open last time, so a refresh lands back where
|
// Which feed (or place) and which tab were open last time, so a refresh lands back where
|
||||||
// you were instead of jumping to the first feed alphabetically.
|
// you were instead of jumping to the first feed alphabetically.
|
||||||
feed:(()=>{ try{ return localStorage.getItem('ipx.feed'); }catch{ return null; } })(),
|
feed:(()=>{ try{ return localStorage.getItem('ipx.feed'); }catch{ return null; } })(),
|
||||||
|
|||||||