Compare commits
50 Commits
directory-
...
directory-
| 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 |
@@ -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`
|
||||||
```
|
```
|
||||||
@@ -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,6 +213,8 @@ 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. The daemon keeps all 20 connections open on Postgres (#241), so `idle` is the pool's 20 less
|
unset. The daemon keeps all 20 connections open on Postgres (#241), so `idle` is the pool's 20 less
|
||||||
@@ -210,6 +231,9 @@ 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.
|
||||||
```
|
```
|
||||||
@@ -253,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}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
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
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
|
||||||
41
CLAUDE.md
41
CLAUDE.md
@@ -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,13 +176,17 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -189,10 +198,34 @@ session was stopped. So:
|
|||||||
run in the background beside another, and never two crates' builds at once. Do not tune `-j`.
|
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
|
* 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.
|
threads twice over, wait for it to come down before starting another.
|
||||||
* Build only the crate the change is in (`cargo build -p ipx-directory`), and run only its tests
|
* Test only what the change is in. ipx uses ipx-directory, and both use ipx-feed, so:
|
||||||
until the change is done; the whole workspace's once, at the end.
|
|
||||||
|
| 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
|
* 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.
|
`/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:
|
||||||
|
|
||||||
|
|||||||
79
Cargo.lock
generated
79
Cargo.lock
generated
@@ -40,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",
|
||||||
@@ -789,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",
|
||||||
@@ -1437,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",
|
||||||
@@ -1555,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]]
|
||||||
@@ -1859,12 +1860,11 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
"atom_syndication",
|
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -1877,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",
|
||||||
@@ -1896,7 +1894,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipx-directory"
|
name = "ipx-directory"
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -1933,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",
|
||||||
@@ -2526,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",
|
||||||
@@ -2985,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",
|
||||||
@@ -2995,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",
|
||||||
@@ -3005,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",
|
||||||
@@ -3015,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",
|
||||||
]
|
]
|
||||||
@@ -3130,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"
|
||||||
@@ -3544,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",
|
||||||
@@ -4279,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",
|
||||||
@@ -4291,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",
|
||||||
@@ -5070,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",
|
||||||
|
|||||||
10
Cargo.toml
10
Cargo.toml
@@ -25,6 +25,7 @@ opentelemetry = { version = "0.33", default-features = false, features = ["trace
|
|||||||
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client", "gzip-http"] }
|
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"] }
|
||||||
@@ -42,3 +43,12 @@ 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,20 @@ 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
|
## [1.2.0] - 2026-10-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -58,7 +72,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- `ipx-directory`, a directory of public podcasts that ipx servers share: each sends the public feeds its people subscribe to, and any server with a token browses them, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205).
|
- `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.2.0...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.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,7 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx-directory"
|
name = "ipx-directory"
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
publish = false
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "ipx-directory"
|
name = "ipx-directory"
|
||||||
|
|||||||
@@ -96,8 +96,11 @@ async fn sync(dir: &Shared, client: &reqwest::Client, country: &str) -> Result<(
|
|||||||
}
|
}
|
||||||
let result = dir.store.snapshot(INSTANCE, &entries).await?;
|
let result = dir.store.snapshot(INSTANCE, &entries).await?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
shows = ids.len(), accepted = result.accepted, refused_private = result.refused_private,
|
shows = ids.len(),
|
||||||
refused_blocked = result.refused_blocked, refused_invalid = result.refused_invalid,
|
accepted = result.accepted,
|
||||||
|
refused_private = result.refused_private,
|
||||||
|
refused_blocked = result.refused_blocked,
|
||||||
|
refused_invalid = result.refused_invalid,
|
||||||
"Apple Podcasts synced"
|
"Apple Podcasts synced"
|
||||||
);
|
);
|
||||||
// Fetched now, not when the refresher next looks.
|
// Fetched now, not when the refresher next looks.
|
||||||
@@ -128,7 +131,11 @@ struct Genre {
|
|||||||
fn categories(g: &Genres) -> BTreeMap<String, Vec<String>> {
|
fn categories(g: &Genres) -> BTreeMap<String, Vec<String>> {
|
||||||
g.0.get("26")
|
g.0.get("26")
|
||||||
.map(|podcasts| {
|
.map(|podcasts| {
|
||||||
podcasts.subgenres.values().map(|c| (c.name.clone(), c.subgenres.values().map(|s| s.name.clone()).collect())).collect()
|
podcasts
|
||||||
|
.subgenres
|
||||||
|
.values()
|
||||||
|
.map(|c| (c.name.clone(), c.subgenres.values().map(|s| s.name.clone()).collect()))
|
||||||
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
@@ -253,7 +260,8 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(chart_ids(&chart), ["1200361736", "1028908750"]);
|
assert_eq!(chart_ids(&chart), ["1200361736", "1028908750"]);
|
||||||
let one: Chart = serde_json::from_str(r#"{"feed": {"entry": {"id": {"attributes": {"im:id": "7"}}}}}"#).unwrap();
|
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");
|
assert_eq!(chart_ids(&one), ["7"], "a chart of one");
|
||||||
let empty: Chart = serde_json::from_str(r#"{"feed": {}}"#).unwrap();
|
let empty: Chart = serde_json::from_str(r#"{"feed": {}}"#).unwrap();
|
||||||
assert!(chart_ids(&empty).is_empty());
|
assert!(chart_ids(&empty).is_empty());
|
||||||
|
|||||||
@@ -162,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);
|
||||||
}
|
}
|
||||||
@@ -171,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());
|
||||||
|
|||||||
@@ -153,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) {
|
||||||
@@ -242,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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,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);
|
||||||
@@ -387,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(
|
||||||
@@ -408,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");
|
||||||
@@ -420,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")));
|
||||||
@@ -434,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
|
||||||
}
|
}
|
||||||
@@ -467,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}"));
|
||||||
@@ -501,7 +536,8 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let resp = app.clone().oneshot(req).await.unwrap();
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
assert_eq!(resp.status(), 200);
|
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();
|
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);
|
assert_eq!(got["accepted"], 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,11 +546,8 @@ mod tests {
|
|||||||
let (app, _) = app().await;
|
let (app, _) = app().await;
|
||||||
for (asked, sent) in [("br", Some("br")), ("zstd", Some("zstd")), ("gzip", Some("gzip")), ("identity", None)] {
|
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.
|
// A page, not an empty feed list: under 32 bytes is sent as it is.
|
||||||
let req = axum::http::Request::builder()
|
let req =
|
||||||
.uri("/")
|
axum::http::Request::builder().uri("/").header("accept-encoding", asked).body(Body::empty()).unwrap();
|
||||||
.header("accept-encoding", asked)
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.clone().oneshot(req).await.unwrap();
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
assert_eq!(resp.status(), 200);
|
assert_eq!(resp.status(), 200);
|
||||||
let got = resp.headers().get("content-encoding").map(|v| v.to_str().unwrap().to_owned());
|
let got = resp.headers().get("content-encoding").map(|v| v.to_str().unwrap().to_owned());
|
||||||
@@ -583,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();
|
||||||
|
|
||||||
@@ -96,10 +105,13 @@ async fn serve(path: &std::path::Path) -> Result<()> {
|
|||||||
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));
|
||||||
}
|
}
|
||||||
@@ -112,7 +124,8 @@ async fn serve(path: &std::path::Path) -> Result<()> {
|
|||||||
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,7 +71,8 @@ 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()
|
||||||
|
.route(
|
||||||
"/metrics",
|
"/metrics",
|
||||||
axum::routing::get(move || async move {
|
axum::routing::get(move || async move {
|
||||||
([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(started))
|
([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(started))
|
||||||
@@ -64,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,7 +130,8 @@ 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> =
|
||||||
|
ls.iter().filter_map(|l| l.subcategory.clone()).fold(BTreeMap::new(), |mut m, s| {
|
||||||
*m.entry(s).or_default() += 1;
|
*m.entry(s).or_default() += 1;
|
||||||
m
|
m
|
||||||
});
|
});
|
||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +118,12 @@ 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"), false).await.unwrap()
|
Self::open(
|
||||||
|
&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `keep_open` for the server, which keeps its whole pool connected.
|
/// `keep_open` for the server, which keeps its whole pool connected.
|
||||||
@@ -195,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 {
|
||||||
@@ -372,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,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> {
|
||||||
@@ -418,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(
|
||||||
@@ -436,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,
|
||||||
@@ -476,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())
|
||||||
}
|
}
|
||||||
@@ -541,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();
|
||||||
@@ -583,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,13 +1083,8 @@ 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())
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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"]),
|
||||||
@@ -1207,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 {
|
||||||
@@ -1227,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");
|
||||||
@@ -1266,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;
|
||||||
@@ -1292,6 +1375,8 @@ mod tests {
|
|||||||
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
|
||||||
|
let huge = get("/huge").await.err().map(|e| format!("{e:#}")).unwrap_or_default();
|
||||||
|
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("/chain").await.unwrap().1, None); // 308 then 302: not for good
|
||||||
assert_eq!(get("/new").await.unwrap().1, None); // never moved
|
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();
|
||||||
@@ -1327,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]
|
||||||
@@ -1344,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>
|
||||||
@@ -1352,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");
|
||||||
}
|
}
|
||||||
@@ -1381,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"));
|
||||||
}
|
}
|
||||||
@@ -1401,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 and a very long list.",
|
||||||
"This week we talk about tape, drums…",
|
"This week we talk about tape, drums…",
|
||||||
"This week we talk about\ntape [...]"] {
|
"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]
|
||||||
@@ -1421,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]
|
||||||
@@ -1454,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());
|
||||||
@@ -1671,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"));
|
||||||
@@ -1777,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,43 @@ 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
|
## [1.3.0] - 2026-10-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -848,7 +885,8 @@ 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.3.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.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,7 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ipx-feed.workspace = true
|
ipx-feed.workspace = true
|
||||||
@@ -10,7 +11,6 @@ ammonia.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
argon2.workspace = true
|
argon2.workspace = true
|
||||||
flate2.workspace = true
|
flate2.workspace = true
|
||||||
atom_syndication.workspace = true
|
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
@@ -20,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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,7 +745,20 @@ 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(
|
||||||
|
"ffprobe",
|
||||||
|
&[
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-select_streams",
|
||||||
|
"a:0",
|
||||||
|
"-show_entries",
|
||||||
|
"packet=duration_time",
|
||||||
|
"-of",
|
||||||
|
"csv=p=0",
|
||||||
|
&p,
|
||||||
|
],
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.lines()
|
.lines()
|
||||||
@@ -718,7 +769,12 @@ mod tests {
|
|||||||
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}"),
|
||||||
@@ -58,9 +62,18 @@ pub fn configured(cfg: &config::Config) -> bool {
|
|||||||
/// A span the whole call, its answer read and parsed included: the directory's own span ends when it
|
/// 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).
|
/// 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))]
|
#[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> {
|
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);
|
||||||
@@ -80,13 +93,19 @@ async fn call<T: DeserializeOwned>(ctx: &Ctx, method: reqwest::Method, path: &st
|
|||||||
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:#}")))?;
|
||||||
tracing::Span::current().record("http.response.body.size", body.len());
|
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:#}")))
|
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<'_> {
|
||||||
@@ -98,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.
|
||||||
@@ -211,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()));
|
||||||
@@ -225,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 {
|
||||||
@@ -254,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");
|
||||||
|
|
||||||
|
|||||||
@@ -78,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>,
|
||||||
@@ -216,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!();
|
||||||
@@ -333,6 +347,10 @@ pub mod signal_verdicts {
|
|||||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
pub guid: String,
|
pub guid: String,
|
||||||
pub hit: bool,
|
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)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|||||||
@@ -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,14 +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 {
|
||||||
|
feed: String,
|
||||||
|
enclosure: i64,
|
||||||
|
saved_s: i64,
|
||||||
|
},
|
||||||
/// Jev found items for someone's Signals (#258), so their page fetches the counts again.
|
/// Jev found items for someone's Signals (#258), so their page fetches the counts again.
|
||||||
SignalHits { user: i64, hits: usize },
|
SignalHits {
|
||||||
Reaped { path: String, bytes: u64 },
|
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,
|
||||||
@@ -47,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 {
|
||||||
@@ -80,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")
|
||||||
}
|
}
|
||||||
@@ -91,7 +141,9 @@ 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::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)"),
|
||||||
})
|
})
|
||||||
@@ -165,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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,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);
|
||||||
@@ -262,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(
|
||||||
@@ -276,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 {
|
||||||
@@ -370,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.
|
||||||
@@ -428,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}");
|
||||||
@@ -441,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");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,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"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,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);
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ mod config;
|
|||||||
mod db;
|
mod db;
|
||||||
mod deadair;
|
mod deadair;
|
||||||
mod directory;
|
mod directory;
|
||||||
mod entity;
|
|
||||||
mod download;
|
mod download;
|
||||||
|
mod entity;
|
||||||
// The crate root's import, so `crate::feed` reaches it from every module as before.
|
// The crate root's import, so `crate::feed` reaches it from every module as before.
|
||||||
use ipx_feed as feed;
|
use ipx_feed as feed;
|
||||||
mod ipc;
|
mod ipc;
|
||||||
@@ -20,9 +20,9 @@ mod web;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use ipc::{Command as Cmd, Emitter, Event};
|
use ipc::{Command as Cmd, Emitter, Event};
|
||||||
|
use std::future::Future;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::future::Future;
|
|
||||||
use tokio::sync::{broadcast, mpsc, watch};
|
use tokio::sync::{broadcast, mpsc, watch};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@@ -200,7 +200,14 @@ const RETRY_LOOKUP_SECS: i64 = 600;
|
|||||||
|
|
||||||
impl Ctx {
|
impl Ctx {
|
||||||
/// When a feed is next due, taking a lookup retry into account: not before it.
|
/// When a feed is next due, taking a lookup retry into account: not before it.
|
||||||
fn due(&self, cfg: &config::Config, feed: &config::Feed, id: &str, state: &db::HttpState, subscribed: bool) -> Option<i64> {
|
fn due(
|
||||||
|
&self,
|
||||||
|
cfg: &config::Config,
|
||||||
|
feed: &config::Feed,
|
||||||
|
id: &str,
|
||||||
|
state: &db::HttpState,
|
||||||
|
subscribed: bool,
|
||||||
|
) -> Option<i64> {
|
||||||
let at = due_at(cfg, feed, state, subscribed);
|
let at = due_at(cfg, feed, state, subscribed);
|
||||||
match self.retry.lock().unwrap().get(id) {
|
match self.retry.lock().unwrap().get(id) {
|
||||||
Some(&retry) => Some(at.unwrap_or(0).max(retry)),
|
Some(&retry) => Some(at.unwrap_or(0).max(retry)),
|
||||||
@@ -250,9 +257,7 @@ impl Ctx {
|
|||||||
|
|
||||||
async fn torrents(&self) -> Result<&torrent::Torrents> {
|
async fn torrents(&self) -> Result<&torrent::Torrents> {
|
||||||
let cfg = self.cfg();
|
let cfg = self.cfg();
|
||||||
self.torrents
|
self.torrents.get_or_try_init(|| torrent::Torrents::new(&cfg)).await
|
||||||
.get_or_try_init(|| torrent::Torrents::new(&cfg))
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,20 +272,19 @@ async fn main() -> Result<()> {
|
|||||||
};
|
};
|
||||||
// Everything goes to stderr as before, and is mirrored into a ring the UI can read.
|
// Everything goes to stderr as before, and is mirrored into a ring the UI can read.
|
||||||
{
|
{
|
||||||
|
use tracing_subscriber::Layer;
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
use tracing_subscriber::Layer;
|
|
||||||
// Two filters, deliberately different. stderr follows IPX_LOG; the in-app buffer
|
// Two filters, deliberately different. stderr follows IPX_LOG; the in-app buffer
|
||||||
// keeps debug as well, so the log view can show protocol traffic and routine
|
// keeps debug as well, so the log view can show protocol traffic and routine
|
||||||
// skips that would be noise on a terminal. IPX_UI_LOG overrides it.
|
// skips that would be noise on a terminal. IPX_UI_LOG overrides it.
|
||||||
let stderr_filter = || {
|
let stderr_filter =
|
||||||
tracing_subscriber::EnvFilter::try_from_env("IPX_LOG").unwrap_or_else(|_| "ipx=info".into())
|
|| tracing_subscriber::EnvFilter::try_from_env("IPX_LOG").unwrap_or_else(|_| "ipx=info".into());
|
||||||
};
|
|
||||||
// One JSON object a line for Loki (#91), with each event's fields as its own; text
|
// One JSON object a line for Loki (#91), with each event's fields as its own; text
|
||||||
// otherwise, for someone reading a terminal.
|
// otherwise, for someone reading a terminal.
|
||||||
let json = std::env::var("IPX_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
|
let json = std::env::var("IPX_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
|
||||||
let ui_filter = tracing_subscriber::EnvFilter::try_from_env("IPX_UI_LOG")
|
let ui_filter =
|
||||||
.unwrap_or_else(|_| "ipx=debug".into());
|
tracing_subscriber::EnvFilter::try_from_env("IPX_UI_LOG").unwrap_or_else(|_| "ipx=debug".into());
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with((!json).then(|| {
|
.with((!json).then(|| {
|
||||||
tracing_subscriber::fmt::layer()
|
tracing_subscriber::fmt::layer()
|
||||||
@@ -294,7 +298,11 @@ async fn main() -> Result<()> {
|
|||||||
tracing_subscriber::fmt::layer()
|
tracing_subscriber::fmt::layer()
|
||||||
.fmt_fields(tracing_subscriber::fmt::format::JsonFields::new())
|
.fmt_fields(tracing_subscriber::fmt::format::JsonFields::new())
|
||||||
.event_format(WithTrace(
|
.event_format(WithTrace(
|
||||||
tracing_subscriber::fmt::format().json().flatten_event(true).with_current_span(true).with_span_list(false),
|
tracing_subscriber::fmt::format()
|
||||||
|
.json()
|
||||||
|
.flatten_event(true)
|
||||||
|
.with_current_span(true)
|
||||||
|
.with_span_list(false),
|
||||||
))
|
))
|
||||||
.with_writer(std::io::stderr)
|
.with_writer(std::io::stderr)
|
||||||
.with_filter(stderr_filter())
|
.with_filter(stderr_filter())
|
||||||
@@ -315,9 +323,7 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
// A daemon owns the state; don't have two processes downloading the same thing.
|
// A daemon owns the state; don't have two processes downloading the same thing.
|
||||||
let wire_cmd = match &cli.command {
|
let wire_cmd = match &cli.command {
|
||||||
Command::Fetch { feed, force } => {
|
Command::Fetch { feed, force } => Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] }),
|
||||||
Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] })
|
|
||||||
}
|
|
||||||
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
|
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
|
||||||
Command::Status => Some(Cmd::Status),
|
Command::Status => Some(Cmd::Status),
|
||||||
Command::List
|
Command::List
|
||||||
@@ -391,11 +397,13 @@ async fn main() -> Result<()> {
|
|||||||
Command::Export { file } => export(&ctx, &file).await,
|
Command::Export { file } => export(&ctx, &file).await,
|
||||||
_ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
|
_ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
|
||||||
};
|
};
|
||||||
if result.is_ok() && changes_catalogue && ipc::daemon_is_live(&socket).await {
|
if result.is_ok()
|
||||||
if let Err(e) = ipc::reload(&socket).await {
|
&& changes_catalogue
|
||||||
|
&& ipc::daemon_is_live(&socket).await
|
||||||
|
&& let Err(e) = ipc::reload(&socket).await
|
||||||
|
{
|
||||||
eprintln!("the running daemon did not take the change ({e:#}); restart it, or it may undo it");
|
eprintln!("the running daemon did not take the change ({e:#}); restart it, or it may undo it");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// The batch exporter holds the last few seconds of spans; without this they are lost.
|
// The batch exporter holds the last few seconds of spans; without this they are lost.
|
||||||
if let Some(p) = otel {
|
if let Some(p) = otel {
|
||||||
let _ = p.shutdown();
|
let _ = p.shutdown();
|
||||||
@@ -459,11 +467,7 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
|
|||||||
if ctx.db.user_by_name(&name).await?.is_some() {
|
if ctx.db.user_by_name(&name).await?.is_some() {
|
||||||
anyhow::bail!("{name} already exists");
|
anyhow::bail!("{name} already exists");
|
||||||
}
|
}
|
||||||
let hash = if no_password {
|
let hash = if no_password { None } else { Some(crate::auth::hash_password(&read_password()?)?) };
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(crate::auth::hash_password(&read_password()?)?)
|
|
||||||
};
|
|
||||||
// The first account runs the place; there is nobody else to grant it.
|
// The first account runs the place; there is nobody else to grant it.
|
||||||
let first = ctx.db.users().await?.is_empty();
|
let first = ctx.db.users().await?.is_empty();
|
||||||
ctx.db.create_user(&name, hash.as_deref(), admin || first).await?;
|
ctx.db.create_user(&name, hash.as_deref(), admin || first).await?;
|
||||||
@@ -496,10 +500,7 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
|
|||||||
}
|
}
|
||||||
UserCmd::Passwd { name } => {
|
UserCmd::Passwd { name } => {
|
||||||
let name = name.trim().to_ascii_lowercase();
|
let name = name.trim().to_ascii_lowercase();
|
||||||
let user = ctx
|
let user = ctx.db.user_by_name(&name).await?.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
|
||||||
.db
|
|
||||||
.user_by_name(&name).await?
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
|
|
||||||
ctx.db.set_password(user.id, &crate::auth::hash_password(&read_password()?)?).await?;
|
ctx.db.set_password(user.id, &crate::auth::hash_password(&read_password()?)?).await?;
|
||||||
println!("password changed for {name}");
|
println!("password changed for {name}");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -509,10 +510,7 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
|
|||||||
// The same rules as a name the proxy vouches for, or the proxy would never find it.
|
// The same rules as a name the proxy vouches for, or the proxy would never find it.
|
||||||
let new_name = crate::auth::name_from_header(&new_name)
|
let new_name = crate::auth::name_from_header(&new_name)
|
||||||
.ok_or_else(|| anyhow::anyhow!("not a usable name: no commas, semicolons or line breaks"))?;
|
.ok_or_else(|| anyhow::anyhow!("not a usable name: no commas, semicolons or line breaks"))?;
|
||||||
let user = ctx
|
let user = ctx.db.user_by_name(&name).await?.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
|
||||||
.db
|
|
||||||
.user_by_name(&name).await?
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
|
|
||||||
if ctx.db.user_by_name(&new_name).await?.is_some() {
|
if ctx.db.user_by_name(&new_name).await?.is_some() {
|
||||||
anyhow::bail!("{new_name} already exists");
|
anyhow::bail!("{new_name} already exists");
|
||||||
}
|
}
|
||||||
@@ -522,10 +520,7 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
|
|||||||
}
|
}
|
||||||
UserCmd::Rm { name } => {
|
UserCmd::Rm { name } => {
|
||||||
let name = name.trim().to_ascii_lowercase();
|
let name = name.trim().to_ascii_lowercase();
|
||||||
let user = ctx
|
let user = ctx.db.user_by_name(&name).await?.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
|
||||||
.db
|
|
||||||
.user_by_name(&name).await?
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
|
|
||||||
remove_user(ctx, user.id).await?;
|
remove_user(ctx, user.id).await?;
|
||||||
println!("removed {name}");
|
println!("removed {name}");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -580,8 +575,8 @@ pub(crate) async fn status(ctx: &Ctx) -> Event {
|
|||||||
use std::sync::atomic::Ordering::Relaxed;
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
let st = &metrics::STATS;
|
let st = &metrics::STATS;
|
||||||
st.feeds.store(feeds as i64, Relaxed);
|
st.feeds.store(feeds as i64, Relaxed);
|
||||||
st.pending.store(pending as i64, Relaxed);
|
st.pending.store(pending, Relaxed);
|
||||||
st.downloaded.store(downloaded as i64, Relaxed);
|
st.downloaded.store(downloaded, Relaxed);
|
||||||
Event::Status { feeds, pending, downloaded }
|
Event::Status { feeds, pending, downloaded }
|
||||||
}
|
}
|
||||||
Err(e) => Event::Error { msg: format!("{e:#}") },
|
Err(e) => Event::Error { msg: format!("{e:#}") },
|
||||||
@@ -654,10 +649,7 @@ async fn daemon(
|
|||||||
let server = tokio::spawn(ipc::serve(socket.clone(), events.clone(), tx_cmd, answer));
|
let server = tokio::spawn(ipc::serve(socket.clone(), events.clone(), tx_cmd, answer));
|
||||||
|
|
||||||
// One command at a time: the queue is what keeps two scans from overlapping.
|
// One command at a time: the queue is what keeps two scans from overlapping.
|
||||||
tracing::info!(
|
tracing::info!(feeds = subscriptions(&ctx).await.map(|s| s.len()).unwrap_or(0), "daemon started");
|
||||||
feeds = subscriptions(&ctx).await.map(|s| s.len()).unwrap_or(0),
|
|
||||||
"daemon started"
|
|
||||||
);
|
|
||||||
|
|
||||||
// A signal has to be able to interrupt work in progress, not just the wait between
|
// A signal has to be able to interrupt work in progress, not just the wait between
|
||||||
// jobs. Racing `shutdown()` in the outer select only cancels branch selection: once
|
// jobs. Racing `shutdown()` in the outer select only cancels branch selection: once
|
||||||
@@ -689,11 +681,7 @@ async fn daemon(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Runs one job, abandoning it if a signal arrives. Returns false to end the loop.
|
// Runs one job, abandoning it if a signal arrives. Returns false to end the loop.
|
||||||
async fn until_stopped(
|
async fn until_stopped(ctx: &Ctx, rx: &watch::Receiver<bool>, job: impl Future<Output = Result<()>>) -> bool {
|
||||||
ctx: &Ctx,
|
|
||||||
rx: &watch::Receiver<bool>,
|
|
||||||
job: impl Future<Output = Result<()>>,
|
|
||||||
) -> bool {
|
|
||||||
let mut stop = rx.clone();
|
let mut stop = rx.clone();
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = stop.changed() => {
|
_ = stop.changed() => {
|
||||||
@@ -783,10 +771,7 @@ async fn start_web(
|
|||||||
config_path.display()
|
config_path.display()
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::info!(
|
tracing::info!("web ui at http://{bind}/ (the sign-in token is [web] token in {})", config_path.display());
|
||||||
"web ui at http://{bind}/ (the sign-in token is [web] token in {})",
|
|
||||||
config_path.display()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bound beyond localhost, as a container has to be for its port to be published. Worth a
|
// Bound beyond localhost, as a container has to be for its port to be published. Worth a
|
||||||
@@ -825,13 +810,7 @@ async fn start_web(
|
|||||||
let at = metrics::spawn(&cfg.web.metrics_bind, sources)?;
|
let at = metrics::spawn(&cfg.web.metrics_bind, sources)?;
|
||||||
tracing::info!(%at, "serving metrics for Prometheus at /metrics");
|
tracing::info!(%at, "serving metrics for Prometheus at /metrics");
|
||||||
}
|
}
|
||||||
let state = web::WebState {
|
let state = web::WebState { ctx: ctx.clone(), cmds: cmds.clone(), events: events.clone(), access, playing };
|
||||||
ctx: ctx.clone(),
|
|
||||||
cmds: cmds.clone(),
|
|
||||||
events: events.clone(),
|
|
||||||
access,
|
|
||||||
playing,
|
|
||||||
};
|
|
||||||
Ok(Some(tokio::spawn(async move {
|
Ok(Some(tokio::spawn(async move {
|
||||||
if let Err(e) = web::serve(state, &bind).await {
|
if let Err(e) = web::serve(state, &bind).await {
|
||||||
tracing::error!(error = %format!("{e:#}"), "web ui stopped");
|
tracing::error!(error = %format!("{e:#}"), "web ui stopped");
|
||||||
@@ -938,10 +917,9 @@ async fn add_one(
|
|||||||
Ok(feed::Fetched::Body { bytes, .. }) if feed::is_opml(&bytes) => {
|
Ok(feed::Fetched::Body { bytes, .. }) if feed::is_opml(&bytes) => {
|
||||||
feed::opml_title(&bytes).unwrap_or_else(|| url_stem(url))
|
feed::opml_title(&bytes).unwrap_or_else(|| url_stem(url))
|
||||||
}
|
}
|
||||||
Ok(feed::Fetched::Body { bytes, .. }) => feed::parse(&bytes)
|
Ok(feed::Fetched::Body { bytes, .. }) => {
|
||||||
.ok()
|
feed::parse(&bytes).ok().and_then(|f| f.title).unwrap_or_else(|| url_stem(url))
|
||||||
.and_then(|f| f.title)
|
}
|
||||||
.unwrap_or_else(|| url_stem(url)),
|
|
||||||
_ => {
|
_ => {
|
||||||
tracing::warn!(url, "could not read the feed; naming it from its URL");
|
tracing::warn!(url, "could not read the feed; naming it from its URL");
|
||||||
url_stem(url)
|
url_stem(url)
|
||||||
@@ -950,10 +928,8 @@ async fn add_one(
|
|||||||
|
|
||||||
// Slugs must be unique across derived feeds too, or a new feed can collide with one
|
// Slugs must be unique across derived feeds too, or a new feed can collide with one
|
||||||
// an OPML already introduced.
|
// an OPML already introduced.
|
||||||
let mut taken: std::collections::BTreeMap<String, config::Feed> = subscriptions(ctx).await?
|
let mut taken: std::collections::BTreeMap<String, config::Feed> =
|
||||||
.into_iter()
|
subscriptions(ctx).await?.into_iter().map(|s| (s.id, s.cfg)).collect();
|
||||||
.map(|s| (s.id, s.cfg))
|
|
||||||
.collect();
|
|
||||||
// A removed feed keeps its rows, so its id is only free again for the same feed: re-adding
|
// A removed feed keeps its rows, so its id is only free again for the same feed: re-adding
|
||||||
// it gets its history back, and a different feed does not inherit someone else's.
|
// it gets its history back, and a different feed does not inherit someone else's.
|
||||||
for (id, other) in ctx.db.feed_urls().await? {
|
for (id, other) in ctx.db.feed_urls().await? {
|
||||||
@@ -968,10 +944,7 @@ async fn add_one(
|
|||||||
|
|
||||||
/// Host plus last path segment, for naming a feed we could not read.
|
/// Host plus last path segment, for naming a feed we could not read.
|
||||||
fn url_stem(url: &str) -> String {
|
fn url_stem(url: &str) -> String {
|
||||||
url::Url::parse(url)
|
url::Url::parse(url).ok().and_then(|u| u.host_str().map(str::to_owned)).unwrap_or_else(|| url.to_owned())
|
||||||
.ok()
|
|
||||||
.and_then(|u| u.host_str().map(str::to_owned))
|
|
||||||
.unwrap_or_else(|| url.to_owned())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn rm(ctx: &Ctx, feed: &str) -> Result<()> {
|
async fn rm(ctx: &Ctx, feed: &str) -> Result<()> {
|
||||||
@@ -991,17 +964,16 @@ async fn rm(ctx: &Ctx, feed: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn import(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
|
async fn import(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
|
||||||
let text = std::fs::read_to_string(file)
|
let text = std::fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;
|
||||||
.with_context(|| format!("reading {}", file.display()))?;
|
|
||||||
// The CLI speaks for the operator, as the shared web token does.
|
// The CLI speaks for the operator, as the shared web token does.
|
||||||
let admin = ctx
|
let admin = ctx
|
||||||
.db
|
.db
|
||||||
.users().await?
|
.users()
|
||||||
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|u| u.is_admin)
|
.find(|u| u.is_admin)
|
||||||
.ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --admin"))?;
|
.ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --admin"))?;
|
||||||
let doc = opml::OPML::from_str(&text)
|
let doc = opml::OPML::from_str(&text).map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?;
|
||||||
.map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?;
|
|
||||||
let (added, had) = subscribe_opml(ctx, &doc, admin.id).await?;
|
let (added, had) = subscribe_opml(ctx, &doc, admin.id).await?;
|
||||||
println!("subscribed {} to {added} feed(s); {had} already there", admin.name);
|
println!("subscribed {} to {added} feed(s); {had} already there", admin.name);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1017,11 +989,7 @@ async fn import(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
|
|||||||
///
|
///
|
||||||
/// The caller parses the document, so each refuses a file that is not OPML in its own terms,
|
/// The caller parses the document, so each refuses a file that is not OPML in its own terms,
|
||||||
/// before anything is touched: a 400 from the web, a message from the CLI.
|
/// before anything is touched: a 400 from the web, a message from the CLI.
|
||||||
pub async fn subscribe_opml(
|
pub async fn subscribe_opml(ctx: &Ctx, doc: &opml::OPML, user_id: i64) -> Result<(usize, usize)> {
|
||||||
ctx: &Ctx,
|
|
||||||
doc: &opml::OPML,
|
|
||||||
user_id: i64,
|
|
||||||
) -> Result<(usize, usize)> {
|
|
||||||
let mut found = vec![];
|
let mut found = vec![];
|
||||||
feed::collect_outlines(&doc.body.outlines, &mut found);
|
feed::collect_outlines(&doc.body.outlines, &mut found);
|
||||||
|
|
||||||
@@ -1108,7 +1076,10 @@ async fn assemble_config(db: &db::Db, mut cfg: config::Config, path: &std::path:
|
|||||||
}
|
}
|
||||||
cfg.save_bootstrap(path)?;
|
cfg.save_bootstrap(path)?;
|
||||||
}
|
}
|
||||||
tracing::info!(feeds = cfg.feeds.len(), "moved the feeds and server settings from config.toml into the database");
|
tracing::info!(
|
||||||
|
feeds = cfg.feeds.len(),
|
||||||
|
"moved the feeds and server settings from config.toml into the database"
|
||||||
|
);
|
||||||
return Ok(cfg);
|
return Ok(cfg);
|
||||||
}
|
}
|
||||||
// Another ipx imported between our look and our insert; the next pass reads its copy.
|
// Another ipx imported between our look and our insert; the next pass reads its copy.
|
||||||
@@ -1117,18 +1088,12 @@ async fn assemble_config(db: &db::Db, mut cfg: config::Config, path: &std::path:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
|
async fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
|
||||||
let mut doc = opml::OPML::default();
|
let mut doc = opml::OPML {
|
||||||
doc.head = Some(opml::Head {
|
head: Some(opml::Head { title: Some("ipx subscriptions".into()), ..Default::default() }),
|
||||||
title: Some("ipx subscriptions".into()),
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
};
|
||||||
for (id, feed) in &ctx.cfg().feeds {
|
for (id, feed) in &ctx.cfg().feeds {
|
||||||
let title = ctx
|
let title = ctx.db.feed_summary(id).await.ok().and_then(|s| s.title).unwrap_or_else(|| id.clone());
|
||||||
.db
|
|
||||||
.feed_summary(id).await
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.title)
|
|
||||||
.unwrap_or_else(|| id.clone());
|
|
||||||
doc.add_feed(&title, &feed.url);
|
doc.add_feed(&title, &feed.url);
|
||||||
}
|
}
|
||||||
let xml = doc.to_string().map_err(|e| anyhow::anyhow!("writing OPML: {e}"))?;
|
let xml = doc.to_string().map_err(|e| anyhow::anyhow!("writing OPML: {e}"))?;
|
||||||
@@ -1178,13 +1143,21 @@ async fn clean_directory(ctx: &Ctx) -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let mut cfg = ctx.edit_cfg().await;
|
let mut cfg = ctx.edit_cfg().await;
|
||||||
if stale.iter().fold(false, |any, id| cfg.feeds.remove(id).is_some() || any) {
|
// Every one removed, so not `any`, which would stop at the first.
|
||||||
|
let mut removed = false;
|
||||||
|
for id in &stale {
|
||||||
|
removed |= cfg.feeds.remove(id).is_some();
|
||||||
|
}
|
||||||
|
if removed {
|
||||||
ctx.store_cfg(cfg).await?;
|
ctx.store_cfg(cfg).await?;
|
||||||
}
|
}
|
||||||
for id in &stale {
|
for id in &stale {
|
||||||
retire_group(ctx, id).await?;
|
retire_group(ctx, id).await?;
|
||||||
ctx.db.forget_feed(id).await?;
|
ctx.db.forget_feed(id).await?;
|
||||||
tracing::info!(feed = id, "removed a feed nobody subscribes to that is dead or has published nothing in a year");
|
tracing::info!(
|
||||||
|
feed = id,
|
||||||
|
"removed a feed nobody subscribes to that is dead or has published nothing in a year"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1201,22 +1174,17 @@ async fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
|
|||||||
span.record("entries", r.entries_pruned);
|
span.record("entries", r.entries_pruned);
|
||||||
if !dry_run {
|
if !dry_run {
|
||||||
tracing::Instrument::instrument(clean_directory(ctx), db::step("clean_directory")).await?;
|
tracing::Instrument::instrument(clean_directory(ctx), db::step("clean_directory")).await?;
|
||||||
let gone = db::step("trim_artwork").in_scope(|| art::trim(&art::dir(), ctx.cfg().general.art_cache_mb * 1_048_576));
|
let gone =
|
||||||
|
db::step("trim_artwork").in_scope(|| art::trim(&art::dir(), ctx.cfg().general.art_cache_mb * 1_048_576));
|
||||||
if gone > 0 {
|
if gone > 0 {
|
||||||
tracing::debug!(gone, "trimmed the artwork kept on disk");
|
tracing::debug!(gone, "trimmed the artwork kept on disk");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for c in r.aged_out.iter().chain(r.over_quota.iter()) {
|
for c in r.aged_out.iter().chain(r.over_quota.iter()) {
|
||||||
ctx.out.emit(Event::Reaped {
|
ctx.out.emit(Event::Reaped { path: c.path.clone(), bytes: c.bytes.max(0) as u64 });
|
||||||
path: c.path.clone(),
|
|
||||||
bytes: c.bytes.max(0) as u64,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if standalone {
|
if standalone {
|
||||||
ctx.out.emit(Event::ReapDone {
|
ctx.out.emit(Event::ReapDone { files: r.aged_out.len() + r.over_quota.len(), bytes: r.bytes_freed });
|
||||||
files: r.aged_out.len() + r.over_quota.len(),
|
|
||||||
bytes: r.bytes_freed,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1235,9 +1203,8 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
|
|||||||
let subscribed = ctx.db.subscriber_counts().await?;
|
let subscribed = ctx.db.subscriber_counts().await?;
|
||||||
let mut scanned = 0;
|
let mut scanned = 0;
|
||||||
let mut fresh: Vec<String> = vec![];
|
let mut fresh: Vec<String> = vec![];
|
||||||
let in_scope = |s: &Sub| {
|
let in_scope =
|
||||||
scope.is_empty() || scope.contains(&s.id) || s.cfg.group.as_ref().is_some_and(|g| scope.contains(g))
|
|s: &Sub| scope.is_empty() || scope.contains(&s.id) || s.cfg.group.as_ref().is_some_and(|g| scope.contains(g));
|
||||||
};
|
|
||||||
let mut states = ctx.db.http_states().await?;
|
let mut states = ctx.db.http_states().await?;
|
||||||
let mut due = vec![];
|
let mut due = vec![];
|
||||||
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id) && in_scope(s)) {
|
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id) && in_scope(s)) {
|
||||||
@@ -1250,15 +1217,16 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
|
|||||||
state.last_modified = None;
|
state.last_modified = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !force && let Some(at) = ctx.due(&cfg, &sub.cfg, id, &state, subscribed.contains_key(id)) {
|
if !force
|
||||||
if at > db::now() {
|
&& let Some(at) = ctx.due(&cfg, &sub.cfg, id, &state, subscribed.contains_key(id))
|
||||||
|
&& at > db::now()
|
||||||
|
{
|
||||||
ctx.out.emit(Event::FeedSkip {
|
ctx.out.emit(Event::FeedSkip {
|
||||||
feed: id.clone(),
|
feed: id.clone(),
|
||||||
reason: format!("not due for {}", duration((at - db::now()) as u64)),
|
reason: format!("not due for {}", duration((at - db::now()) as u64)),
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
due.push((sub, state));
|
due.push((sub, state));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1281,7 +1249,9 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let _fetching = metrics::busy(&metrics::STATS.feeds_fetching);
|
let _fetching = metrics::busy(&metrics::STATS.feeds_fetching);
|
||||||
let got = feed::fetch(&client, &feed_cfg.url, feed_cfg.auth(), etag.as_deref(), modified.as_deref()).await;
|
let got =
|
||||||
|
feed::fetch(&client, &feed_cfg.url, feed_cfg.auth(), etag.as_deref(), modified.as_deref())
|
||||||
|
.await;
|
||||||
// A forced scan looks for the site's icon of each feed naming no artwork: here,
|
// A forced scan looks for the site's icon of each feed naming no artwork: here,
|
||||||
// a few feeds at a time, not each in its turn, 22 s of a 62 s scan (#228).
|
// a few feeds at a time, not each in its turn, 22 s of a 62 s scan (#228).
|
||||||
let icon = match &got {
|
let icon = match &got {
|
||||||
@@ -1314,14 +1284,10 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
|
|||||||
failed: s.failed,
|
failed: s.failed,
|
||||||
torrents: s.torrents,
|
torrents: s.torrents,
|
||||||
}),
|
}),
|
||||||
Ok(Outcome::NotModified) => ctx.out.emit(Event::FeedSkip {
|
Ok(Outcome::NotModified) => {
|
||||||
feed: id.clone(),
|
ctx.out.emit(Event::FeedSkip { feed: id.clone(), reason: "not modified".into() })
|
||||||
reason: "not modified".into(),
|
}
|
||||||
}),
|
Ok(Outcome::Empty) => ctx.out.emit(Event::FeedSkip { feed: id.clone(), reason: "nothing yet".into() }),
|
||||||
Ok(Outcome::Empty) => ctx.out.emit(Event::FeedSkip {
|
|
||||||
feed: id.clone(),
|
|
||||||
reason: "nothing yet".into(),
|
|
||||||
}),
|
|
||||||
Ok(Outcome::Opml { added, removed, kept, total }) => {
|
Ok(Outcome::Opml { added, removed, kept, total }) => {
|
||||||
ctx.out.emit(Event::FeedSkip {
|
ctx.out.emit(Event::FeedSkip {
|
||||||
feed: id.clone(),
|
feed: id.clone(),
|
||||||
@@ -1390,11 +1356,8 @@ pub struct Sub {
|
|||||||
/// entry -- there is nothing to store but its URL and where it came from.
|
/// entry -- there is nothing to store but its URL and where it came from.
|
||||||
pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
|
pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
|
||||||
let cfg = ctx.cfg();
|
let cfg = ctx.cfg();
|
||||||
let mut out: Vec<Sub> = cfg
|
let mut out: Vec<Sub> =
|
||||||
.feeds
|
cfg.feeds.iter().map(|(id, f)| Sub { id: id.clone(), cfg: f.clone(), managed: false }).collect();
|
||||||
.iter()
|
|
||||||
.map(|(id, f)| Sub { id: id.clone(), cfg: f.clone(), managed: false })
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// A group's title, once a group: asked for each feed in it, three queries a feed, it was about
|
// A group's title, once a group: asked for each feed in it, three queries a feed, it was about
|
||||||
// 96 for The Glass Cannon Network's 32 shows on every feed list, live update and scan (#159).
|
// 96 for The Glass Cannon Network's 32 shows on every feed list, live update and scan (#159).
|
||||||
@@ -1451,7 +1414,11 @@ pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
|
|||||||
/// lists are left alone.
|
/// lists are left alone.
|
||||||
pub async fn leave(ctx: &Ctx, user_id: i64, id: &str) -> Result<()> {
|
pub async fn leave(ctx: &Ctx, user_id: i64, id: &str) -> Result<()> {
|
||||||
ctx.db.unsubscribe(user_id, id).await?;
|
ctx.db.unsubscribe(user_id, id).await?;
|
||||||
for child in subscriptions(ctx).await?.iter().filter(|s| s.cfg.group.as_deref() == Some(id)) {
|
// What they kept stays on their list, a feed of its own once nobody reads the list (#280).
|
||||||
|
let kept = ctx.db.kept_feeds(user_id).await?;
|
||||||
|
for child in
|
||||||
|
subscriptions(ctx).await?.iter().filter(|s| s.cfg.group.as_deref() == Some(id) && !kept.contains(&s.id))
|
||||||
|
{
|
||||||
ctx.db.unsubscribe(user_id, &child.id).await?;
|
ctx.db.unsubscribe(user_id, &child.id).await?;
|
||||||
}
|
}
|
||||||
if ctx.db.subscriber_counts().await?.contains_key(id) {
|
if ctx.db.subscriber_counts().await?.contains_key(id) {
|
||||||
@@ -1504,6 +1471,8 @@ pub async fn retire_group(ctx: &Ctx, parent_id: &str) -> Result<()> {
|
|||||||
// Scanned from its config entry and still read. Dropped as derived, its stored
|
// Scanned from its config entry and still read. Dropped as derived, its stored
|
||||||
// entries would go with it: davewiner's 11 were promoted without being unmanaged.
|
// entries would go with it: davewiner's 11 were promoted without being unmanaged.
|
||||||
ctx.db.unmanage(&m.id).await?;
|
ctx.db.unmanage(&m.id).await?;
|
||||||
|
} else if promote_kept(ctx, &m).await? {
|
||||||
|
tracing::info!(feed = %m.id, "its list is gone but someone kept it; a feed of its own now");
|
||||||
} else if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 {
|
} else if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 {
|
||||||
ctx.db.set_orphaned(&m.id, true).await?;
|
ctx.db.set_orphaned(&m.id, true).await?;
|
||||||
} else {
|
} else {
|
||||||
@@ -1513,6 +1482,46 @@ pub async fn retire_group(ctx: &Ctx, parent_id: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Makes a feed from an OPML or Patreon list a feed of its own, for those who kept it (#280): a
|
||||||
|
/// catalogue entry in no group, which the list no longer adds or takes away. Whoever had it only
|
||||||
|
/// through the list, without keeping it, loses it, as they would have when the list dropped it.
|
||||||
|
/// False, changing nothing, when nobody kept it.
|
||||||
|
async fn promote_kept(ctx: &Ctx, m: &db::Managed) -> Result<bool> {
|
||||||
|
let subs = ctx.db.subscribers_of(&m.id).await?;
|
||||||
|
if !subs.iter().any(|(_, kept)| *kept) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let mut cfg = ctx.edit_cfg().await;
|
||||||
|
// The list's own settings carry over while it is there; its folder and group do not.
|
||||||
|
let base = cfg.feeds.get(&m.group_id).cloned();
|
||||||
|
cfg.feeds.entry(m.id.clone()).or_insert_with(|| config::Feed {
|
||||||
|
url: m.url.clone(),
|
||||||
|
folder: None,
|
||||||
|
group: None,
|
||||||
|
media_types: base.as_ref().and_then(|p| p.media_types.clone()),
|
||||||
|
schedule: base.as_ref().and_then(|p| p.schedule.clone()),
|
||||||
|
keywords: base.as_ref().map(|p| p.keywords.clone()).unwrap_or_default(),
|
||||||
|
allow_explicit: base.as_ref().is_some_and(|p| p.allow_explicit),
|
||||||
|
auto_download: base.as_ref().is_none_or(|p| p.auto_download),
|
||||||
|
max_new_per_check: base.as_ref().and_then(|p| p.max_new_per_check),
|
||||||
|
username: base.as_ref().and_then(|p| p.username.clone()),
|
||||||
|
password: base.as_ref().and_then(|p| p.password.clone()),
|
||||||
|
password_env: base.as_ref().and_then(|p| p.password_env.clone()),
|
||||||
|
category: None,
|
||||||
|
listed: false,
|
||||||
|
private: base.as_ref().is_some_and(|p| p.private),
|
||||||
|
});
|
||||||
|
ctx.store_cfg(cfg).await?;
|
||||||
|
ctx.db.unmanage(&m.id).await?;
|
||||||
|
ctx.db.set_orphaned(&m.id, false).await?;
|
||||||
|
for (user, kept) in subs {
|
||||||
|
if !kept {
|
||||||
|
ctx.db.unsubscribe(user, &m.id).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Retires every group whose parent is gone from config, and returns how many derived rows that
|
/// Retires every group whose parent is gone from config, and returns how many derived rows that
|
||||||
/// dropped or unmanaged. An OPML removed before `retire_group` existed left its feeds behind:
|
/// dropped or unmanaged. An OPML removed before `retire_group` existed left its feeds behind:
|
||||||
/// davewiner's 922 were skipped by every scan and never cleared, and their stale errors were
|
/// davewiner's 922 were skipped by every scan and never cleared, and their stale errors were
|
||||||
@@ -1520,11 +1529,8 @@ pub async fn retire_group(ctx: &Ctx, parent_id: &str) -> Result<()> {
|
|||||||
async fn retire_stranded(ctx: &Ctx) -> Result<usize> {
|
async fn retire_stranded(ctx: &Ctx) -> Result<usize> {
|
||||||
let cfg = ctx.cfg();
|
let cfg = ctx.cfg();
|
||||||
let before = ctx.db.managed_feeds().await?;
|
let before = ctx.db.managed_feeds().await?;
|
||||||
let stranded: std::collections::BTreeSet<&str> = before
|
let stranded: std::collections::BTreeSet<&str> =
|
||||||
.iter()
|
before.iter().map(|m| m.group_id.as_str()).filter(|g| !cfg.feeds.contains_key(*g)).collect();
|
||||||
.map(|m| m.group_id.as_str())
|
|
||||||
.filter(|g| !cfg.feeds.contains_key(*g))
|
|
||||||
.collect();
|
|
||||||
for group in stranded {
|
for group in stranded {
|
||||||
retire_group(ctx, group).await?;
|
retire_group(ctx, group).await?;
|
||||||
}
|
}
|
||||||
@@ -1592,7 +1598,10 @@ pub fn due_after(
|
|||||||
let usual = match explicit {
|
let usual = match explicit {
|
||||||
Some(explicit) => explicit * 60,
|
Some(explicit) => explicit * 60,
|
||||||
// A schedule someone set is theirs; otherwise a feed long the same is checked less.
|
// A schedule someone set is theirs; otherwise a feed long the same is checked less.
|
||||||
None => quiet(ttl_mins.unwrap_or(0).max(cfg.general.interval()) * 60, same_since.map(|s| (last_checked - s).max(0) as u64)),
|
None => quiet(
|
||||||
|
ttl_mins.unwrap_or(0).max(cfg.general.interval()) * 60,
|
||||||
|
same_since.map(|s| (last_checked - s).max(0) as u64),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
let failing_for = error_since.map(|since| (last_checked - since).max(0) as u64);
|
let failing_for = error_since.map(|since| (last_checked - since).max(0) as u64);
|
||||||
backoff(usual, failing_for)
|
backoff(usual, failing_for)
|
||||||
@@ -1638,7 +1647,12 @@ enum Outcome {
|
|||||||
Empty,
|
Empty,
|
||||||
Feed(Scan),
|
Feed(Scan),
|
||||||
/// The URL is a list of feeds rather than a feed: an OPML, or a Patreon creator's shows.
|
/// The URL is a list of feeds rather than a feed: an OPML, or a Patreon creator's shows.
|
||||||
Opml { added: Vec<String>, removed: usize, kept: usize, total: usize },
|
Opml {
|
||||||
|
added: Vec<String>,
|
||||||
|
removed: usize,
|
||||||
|
kept: usize,
|
||||||
|
total: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A feed that answered from a new address after permanent redirects moves there in the
|
/// A feed that answered from a new address after permanent redirects moves there in the
|
||||||
@@ -1668,7 +1682,7 @@ async fn scan_one(
|
|||||||
force: bool,
|
force: bool,
|
||||||
// The body the scan already fetched for it, if it did (#104), and where it moved, if it did;
|
// The body the scan already fetched for it, if it did (#104), and where it moved, if it did;
|
||||||
// with the site's icon when a forced scan looked it up meanwhile, "" for none (#228).
|
// with the site's icon when a forced scan looked it up meanwhile, "" for none (#228).
|
||||||
prefetched: Option<(Result<(feed::Fetched, Option<String>)>, Option<String>)>,
|
prefetched: Option<Prefetched>,
|
||||||
) -> Result<Outcome> {
|
) -> Result<Outcome> {
|
||||||
let (prefetched, icon) = match prefetched {
|
let (prefetched, icon) = match prefetched {
|
||||||
Some((got, icon)) => (Some(got), icon),
|
Some((got, icon)) => (Some(got), icon),
|
||||||
@@ -1705,7 +1719,16 @@ async fn scan_one(
|
|||||||
|
|
||||||
let (mut fetched, moved) = match prefetched {
|
let (mut fetched, moved) = match prefetched {
|
||||||
Some(got) => got?,
|
Some(got) => got?,
|
||||||
None => feed::fetch(&ctx.feed_client, &feed_cfg.url, feed_cfg.auth(), state.etag.as_deref(), state.last_modified.as_deref()).await?,
|
None => {
|
||||||
|
feed::fetch(
|
||||||
|
&ctx.feed_client,
|
||||||
|
&feed_cfg.url,
|
||||||
|
feed_cfg.auth(),
|
||||||
|
state.etag.as_deref(),
|
||||||
|
state.last_modified.as_deref(),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if let Some(to) = moved {
|
if let Some(to) = moved {
|
||||||
follow_move(ctx, id, &to).await?;
|
follow_move(ctx, id, &to).await?;
|
||||||
@@ -1815,7 +1838,8 @@ async fn scan_one(
|
|||||||
ctx.db.secure_images(id, host).await?;
|
ctx.db.secure_images(id, host).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.db.record_feed(
|
ctx.db
|
||||||
|
.record_feed(
|
||||||
id,
|
id,
|
||||||
&feed_cfg.url,
|
&feed_cfg.url,
|
||||||
parsed.title.as_deref(),
|
parsed.title.as_deref(),
|
||||||
@@ -1825,7 +1849,8 @@ async fn scan_one(
|
|||||||
parsed.image.as_deref(),
|
parsed.image.as_deref(),
|
||||||
parsed.category.as_deref(),
|
parsed.category.as_deref(),
|
||||||
parsed.description.as_deref(),
|
parsed.description.as_deref(),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
if state.body_hash.as_deref() != Some(hash.as_str()) {
|
if state.body_hash.as_deref() != Some(hash.as_str()) {
|
||||||
ctx.db.set_body(id, &hash).await?;
|
ctx.db.set_body(id, &hash).await?;
|
||||||
}
|
}
|
||||||
@@ -1837,7 +1862,7 @@ async fn scan_one(
|
|||||||
&& (force || stored.entries == 0)
|
&& (force || stored.entries == 0)
|
||||||
&& !parsed.entries.is_empty()
|
&& !parsed.entries.is_empty()
|
||||||
&& ctx.cfg().feeds.get(id).is_some_and(|f| f.category.is_none())
|
&& ctx.cfg().feeds.get(id).is_some_and(|f| f.category.is_none())
|
||||||
&& let Ok(key) = std::env::var("TYPESAFE_KEY")
|
&& let Some(key) = signals::typesafe_key()
|
||||||
{
|
{
|
||||||
match feed::classify(&ctx.client, &key, &parsed).await {
|
match feed::classify(&ctx.client, &key, &parsed).await {
|
||||||
Ok(category) => {
|
Ok(category) => {
|
||||||
@@ -1870,7 +1895,8 @@ async fn scan_one(
|
|||||||
let mut scan = Scan::default();
|
let mut scan = Scan::default();
|
||||||
// Its own span: the time a feed spends after its fetch was untraced (#96).
|
// Its own span: the time a feed spends after its fetch was untraced (#96).
|
||||||
let store = tracing::info_span!("store", items = parsed.entries.len());
|
let store = tracing::info_span!("store", items = parsed.entries.len());
|
||||||
tracing::Instrument::instrument(async {
|
tracing::Instrument::instrument(
|
||||||
|
async {
|
||||||
for entry in &parsed.entries {
|
for entry in &parsed.entries {
|
||||||
// What is not stored yet is inserted, and what the feed has changed since is written
|
// What is not stored yet is inserted, and what the feed has changed since is written
|
||||||
// again (#141); the rest is left alone. A correction keeps the item's read state.
|
// again (#141); the rest is left alone. A correction keeps the item's read state.
|
||||||
@@ -1881,7 +1907,8 @@ async fn scan_one(
|
|||||||
}
|
}
|
||||||
for enc in &entry.enclosures {
|
for enc in &entry.enclosures {
|
||||||
// A URL not among this feed's files may still be another feed's: the insert says.
|
// A URL not among this feed's files may still be another feed's: the insert says.
|
||||||
let was = if !known_files.contains(&enc.url) && ctx.db.record_enclosure(id, &entry.guid, enc).await? {
|
let was =
|
||||||
|
if !known_files.contains(&enc.url) && ctx.db.record_enclosure(id, &entry.guid, enc).await? {
|
||||||
None
|
None
|
||||||
} else if let Some(reason) = skipped.get(&enc.url) {
|
} else if let Some(reason) = skipped.get(&enc.url) {
|
||||||
Some(reason.as_str())
|
Some(reason.as_str())
|
||||||
@@ -1898,7 +1925,10 @@ async fn scan_one(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
anyhow::Ok(())
|
anyhow::Ok(())
|
||||||
}, store).await?;
|
},
|
||||||
|
store,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
if scan.new_entries > 0 {
|
if scan.new_entries > 0 {
|
||||||
ctx.db.rehide(id).await?; // what is new may hold someone's blocked words
|
ctx.db.rehide(id).await?; // what is new may hold someone's blocked words
|
||||||
ctx.signals.notify_one();
|
ctx.signals.notify_one();
|
||||||
@@ -1917,10 +1947,7 @@ async fn scan_one(
|
|||||||
if download::looks_like_torrent(&item.url, item.mime.as_deref()) {
|
if download::looks_like_torrent(&item.url, item.mime.as_deref()) {
|
||||||
if !ctx.cfg().torrent.enabled {
|
if !ctx.cfg().torrent.enabled {
|
||||||
ctx.db.mark_enclosure(&item.url, "skipped", Some("torrents disabled")).await?;
|
ctx.db.mark_enclosure(&item.url, "skipped", Some("torrents disabled")).await?;
|
||||||
ctx.out.emit(Event::TorrentDeferred {
|
ctx.out.emit(Event::TorrentDeferred { feed: id.to_string(), url: item.url.clone() });
|
||||||
feed: id.to_string(),
|
|
||||||
url: item.url.clone(),
|
|
||||||
});
|
|
||||||
scan.torrents += 1;
|
scan.torrents += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1987,12 +2014,10 @@ async fn scan_one(
|
|||||||
Ok(Outcome::Feed(scan))
|
Ok(Outcome::Feed(scan))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sync_opml(
|
/// A feed's body fetched by the scan ahead of its turn, and the site's icon if one was looked up.
|
||||||
ctx: &Arc<Ctx>,
|
type Prefetched = (Result<(feed::Fetched, Option<String>)>, Option<String>);
|
||||||
parent_id: &str,
|
|
||||||
parent: &config::Feed,
|
async fn sync_opml(ctx: &Arc<Ctx>, parent_id: &str, parent: &config::Feed, bytes: &[u8]) -> Result<Outcome> {
|
||||||
bytes: &[u8],
|
|
||||||
) -> Result<Outcome> {
|
|
||||||
let listed = feed::parse_opml(bytes)?;
|
let listed = feed::parse_opml(bytes)?;
|
||||||
if let Some(title) = feed::opml_title(bytes) {
|
if let Some(title) = feed::opml_title(bytes) {
|
||||||
ctx.db.set_title(parent_id, &title).await?;
|
ctx.db.set_title(parent_id, &title).await?;
|
||||||
@@ -2046,7 +2071,8 @@ async fn sync_group(
|
|||||||
// subscription means. Their own feeds are untouched.
|
// subscription means. Their own feeds are untouched.
|
||||||
for id in ctx
|
for id in ctx
|
||||||
.db
|
.db
|
||||||
.managed_feeds().await?
|
.managed_feeds()
|
||||||
|
.await?
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|m| m.group_id == parent_id)
|
.filter(|m| m.group_id == parent_id)
|
||||||
.map(|m| m.id.clone())
|
.map(|m| m.id.clone())
|
||||||
@@ -2066,6 +2092,11 @@ async fn sync_group(
|
|||||||
if listed.iter().any(|(_, u)| u == &m.url) {
|
if listed.iter().any(|(_, u)| u == &m.url) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if promote_kept(ctx, m).await? {
|
||||||
|
kept += 1;
|
||||||
|
tracing::info!(feed = %m.id, "dropped from the OPML but someone kept it; a feed of its own now");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 {
|
if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 {
|
||||||
// Never orphan a downloaded file: keep the feed and say why in the UI.
|
// Never orphan a downloaded file: keep the feed and say why in the UI.
|
||||||
ctx.db.set_orphaned(&m.id, true).await?;
|
ctx.db.set_orphaned(&m.id, true).await?;
|
||||||
@@ -2095,10 +2126,7 @@ fn reject(
|
|||||||
}
|
}
|
||||||
// Blog feeds put the article's header image in an <enclosure>; without this a text
|
// Blog feeds put the article's header image in an <enclosure>; without this a text
|
||||||
// feed reads as a podcast full of episodes and fills the disk with artwork.
|
// feed reads as a podcast full of episodes and fills the disk with artwork.
|
||||||
let wanted = feed_cfg
|
let wanted = feed_cfg.media_types.as_deref().unwrap_or(&cfg.general.media_types);
|
||||||
.media_types
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or(&cfg.general.media_types);
|
|
||||||
if !config::wanted_media(enc.mime.as_deref(), wanted) {
|
if !config::wanted_media(enc.mime.as_deref(), wanted) {
|
||||||
return Some("not audio or video");
|
return Some("not audio or video");
|
||||||
}
|
}
|
||||||
@@ -2106,12 +2134,8 @@ fn reject(
|
|||||||
return Some("explicit");
|
return Some("explicit");
|
||||||
}
|
}
|
||||||
let categories = entry.categories.join(" ");
|
let categories = entry.categories.join(" ");
|
||||||
let haystacks = [
|
let haystacks =
|
||||||
url,
|
[url, entry.title.as_deref().unwrap_or(""), entry.description.as_deref().unwrap_or(""), categories.as_str()];
|
||||||
entry.title.as_deref().unwrap_or(""),
|
|
||||||
entry.description.as_deref().unwrap_or(""),
|
|
||||||
categories.as_str(),
|
|
||||||
];
|
|
||||||
let text = [entry.title.as_deref().unwrap_or(""), entry.description.as_deref().unwrap_or("")];
|
let text = [entry.title.as_deref().unwrap_or(""), entry.description.as_deref().unwrap_or("")];
|
||||||
if !policy.wanted(&haystacks, &text) {
|
if !policy.wanted(&haystacks, &text) {
|
||||||
// Worded for whichever filter could have let it through, the keywords if none blocked it.
|
// Worded for whichever filter could have let it through, the keywords if none blocked it.
|
||||||
@@ -2145,9 +2169,9 @@ impl Policy {
|
|||||||
/// One file serves everyone subscribed, so an item is wanted if anyone wants it. Keywords
|
/// One file serves everyone subscribed, so an item is wanted if anyone wants it. Keywords
|
||||||
/// look at `haystacks`, blocked words at `text`, the item's title and body only.
|
/// look at `haystacks`, blocked words at `text`, the item's title and body only.
|
||||||
fn wanted(&self, haystacks: &[&str], text: &[&str]) -> bool {
|
fn wanted(&self, haystacks: &[&str], text: &[&str]) -> bool {
|
||||||
self.wants.iter().any(|w| {
|
self.wants
|
||||||
download::matches_keywords(&w.keywords, haystacks) && !download::blocked(&w.blocked, text)
|
.iter()
|
||||||
})
|
.any(|w| download::matches_keywords(&w.keywords, haystacks) && !download::blocked(&w.blocked, text))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2176,21 +2200,15 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut policy = Policy {
|
let mut policy = Policy { auto_download: false, allow_explicit: false, wants: vec![], budget: 0 };
|
||||||
auto_download: false,
|
|
||||||
allow_explicit: false,
|
|
||||||
wants: vec![],
|
|
||||||
budget: 0,
|
|
||||||
};
|
|
||||||
for sub in subs {
|
for sub in subs {
|
||||||
if !sub.auto_download.unwrap_or(feed_cfg.auto_download) {
|
if !sub.auto_download.unwrap_or(feed_cfg.auto_download) {
|
||||||
continue; // Not fetching for this person, so their wants add nothing.
|
continue; // Not fetching for this person, so their wants add nothing.
|
||||||
}
|
}
|
||||||
policy.auto_download = true;
|
policy.auto_download = true;
|
||||||
policy.allow_explicit |= sub.allow_explicit.unwrap_or(feed_cfg.allow_explicit);
|
policy.allow_explicit |= sub.allow_explicit.unwrap_or(feed_cfg.allow_explicit);
|
||||||
policy.budget = policy
|
policy.budget =
|
||||||
.budget
|
policy.budget.max(cap(sub.max_new_per_check.map(|n| n as usize).or(feed_cfg.max_new_per_check)));
|
||||||
.max(cap(sub.max_new_per_check.map(|n| n as usize).or(feed_cfg.max_new_per_check)));
|
|
||||||
policy.wants.push(Want {
|
policy.wants.push(Want {
|
||||||
keywords: sub.keywords.clone().unwrap_or_else(|| feed_cfg.keywords.clone()),
|
keywords: sub.keywords.clone().unwrap_or_else(|| feed_cfg.keywords.clone()),
|
||||||
blocked: sub.blocked.clone(),
|
blocked: sub.blocked.clone(),
|
||||||
@@ -2297,10 +2315,7 @@ fn spawn_torrent(ctx: &Arc<Ctx>, feed_id: String, enclosure: i64, url: String, d
|
|||||||
#[tracing::instrument(skip(ctx))]
|
#[tracing::instrument(skip(ctx))]
|
||||||
async fn download_one(ctx: &Arc<Ctx>, id: i64) -> Result<()> {
|
async fn download_one(ctx: &Arc<Ctx>, id: i64) -> Result<()> {
|
||||||
let cfg = ctx.cfg();
|
let cfg = ctx.cfg();
|
||||||
let enc = ctx
|
let enc = ctx.db.enclosure(id).await?.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
|
||||||
.db
|
|
||||||
.enclosure(id).await?
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
|
|
||||||
if enc.path.is_some() {
|
if enc.path.is_some() {
|
||||||
return Ok(()); // Already here.
|
return Ok(()); // Already here.
|
||||||
}
|
}
|
||||||
@@ -2379,9 +2394,9 @@ async fn torrent_one(
|
|||||||
ctx.torrents()
|
ctx.torrents()
|
||||||
.await?
|
.await?
|
||||||
.fetch(&cfg, url, dest_dir, |done, total| {
|
.fetch(&cfg, url, dest_dir, |done, total| {
|
||||||
if total > 0 {
|
if let Some(pct) = (done * 100).checked_div(total).map(|p| p as i64)
|
||||||
let pct = (done * 100 / total) as i64;
|
&& pct > last_pct
|
||||||
if pct > last_pct {
|
{
|
||||||
last_pct = pct;
|
last_pct = pct;
|
||||||
ctx.out.emit(Event::Progress {
|
ctx.out.emit(Event::Progress {
|
||||||
feed: feed_id.to_string(),
|
feed: feed_id.to_string(),
|
||||||
@@ -2392,7 +2407,6 @@ async fn torrent_one(
|
|||||||
total: Some(total),
|
total: Some(total),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -2447,14 +2461,20 @@ mod tests {
|
|||||||
let base = empty_or_feed_server().await;
|
let base = empty_or_feed_server().await;
|
||||||
let ctx = Arc::new(test_ctx(config::Config::default()).await);
|
let ctx = Arc::new(test_ctx(config::Config::default()).await);
|
||||||
let cfg = config::Feed { url: format!("{base}/feed"), ..feed() };
|
let cfg = config::Feed { url: format!("{base}/feed"), ..feed() };
|
||||||
assert!(matches!(scan_one(&ctx, "same", &cfg, &db::HttpState::default(), false, None).await, Ok(Outcome::Feed(_))));
|
assert!(matches!(
|
||||||
|
scan_one(&ctx, "same", &cfg, &db::HttpState::default(), false, None).await,
|
||||||
|
Ok(Outcome::Feed(_))
|
||||||
|
));
|
||||||
let state = ctx.db.http_state("same").await.unwrap();
|
let state = ctx.db.http_state("same").await.unwrap();
|
||||||
let since = state.same_since.expect("when it was first read");
|
let since = state.same_since.expect("when it was first read");
|
||||||
assert!(state.body_hash.is_some());
|
assert!(state.body_hash.is_some());
|
||||||
// Patreon's way: the whole feed again, no validator honoured (#242).
|
// Patreon's way: the whole feed again, no validator honoured (#242).
|
||||||
assert!(matches!(scan_one(&ctx, "same", &cfg, &state, false, None).await, Ok(Outcome::NotModified)));
|
assert!(matches!(scan_one(&ctx, "same", &cfg, &state, false, None).await, Ok(Outcome::NotModified)));
|
||||||
assert_eq!(ctx.db.http_state("same").await.unwrap().same_since, Some(since), "still the same since then");
|
assert_eq!(ctx.db.http_state("same").await.unwrap().same_since, Some(since), "still the same since then");
|
||||||
assert!(matches!(scan_one(&ctx, "same", &cfg, &state, true, None).await, Ok(Outcome::Feed(_))), "a forced check reads it");
|
assert!(
|
||||||
|
matches!(scan_one(&ctx, "same", &cfg, &state, true, None).await, Ok(Outcome::Feed(_))),
|
||||||
|
"a forced check reads it"
|
||||||
|
);
|
||||||
// Changed: read, and the same from now.
|
// Changed: read, and the same from now.
|
||||||
let moved = config::Feed { url: format!("{base}/fixed"), ..feed() };
|
let moved = config::Feed { url: format!("{base}/fixed"), ..feed() };
|
||||||
assert!(matches!(scan_one(&ctx, "same", &moved, &state, false, None).await, Ok(Outcome::Feed(_))));
|
assert!(matches!(scan_one(&ctx, "same", &moved, &state, false, None).await, Ok(Outcome::Feed(_))));
|
||||||
@@ -2514,8 +2534,8 @@ mod tests {
|
|||||||
fn feed() -> config::Feed {
|
fn feed() -> config::Feed {
|
||||||
// Whatever `ipx add` would write, which is the shape every code path sees.
|
// Whatever `ipx add` would write, which is the shape every code path sees.
|
||||||
let mut cfg = config::Config::default();
|
let mut cfg = config::Config::default();
|
||||||
let f = add_one_cfg(&mut cfg, "http://x/f.xml", None, vec![]);
|
|
||||||
f
|
add_one_cfg(&mut cfg, "http://x/f.xml", None, vec![])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The feed entry `add` builds, without the network round trip it does for a title.
|
/// The feed entry `add` builds, without the network round trip it does for a title.
|
||||||
@@ -2565,11 +2585,7 @@ mod tests {
|
|||||||
|
|
||||||
// Two filters: an item wanted by either of them is fetched, since one file serves
|
// Two filters: an item wanted by either of them is fetched, since one file serves
|
||||||
// both. The larger per-scan cap wins for the same reason.
|
// both. The larger per-scan cap wins for the same reason.
|
||||||
let p = merge_policy(
|
let p = merge_policy(&[sub(Some(&["rust"]), None, Some(2)), sub(Some(&["sqlite"]), None, Some(9))], &feed(), 3);
|
||||||
&[sub(Some(&["rust"]), None, Some(2)), sub(Some(&["sqlite"]), None, Some(9))],
|
|
||||||
&feed(),
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
assert!(p.wanted(&["rust"], &[]) && p.wanted(&["sqlite"], &[]));
|
assert!(p.wanted(&["rust"], &[]) && p.wanted(&["sqlite"], &[]));
|
||||||
assert!(!p.wanted(&["python"], &[]));
|
assert!(!p.wanted(&["python"], &[]));
|
||||||
assert_eq!(p.budget, 9);
|
assert_eq!(p.budget, 9);
|
||||||
@@ -2643,19 +2659,34 @@ mod tests {
|
|||||||
let (name, value) = line.rsplit_once(' ').unwrap();
|
let (name, value) = line.rsplit_once(' ').unwrap();
|
||||||
assert!(name.starts_with("ipx_") && value.parse::<f64>().is_ok(), "{line}");
|
assert!(name.starts_with("ipx_") && value.parse::<f64>().is_ok(), "{line}");
|
||||||
}
|
}
|
||||||
for name in ["ipx_db_connections{state=\"in_use\"}", "ipx_db_connections_max", "ipx_worker_queue", "ipx_runtime_tasks"] {
|
for name in
|
||||||
|
["ipx_db_connections{state=\"in_use\"}", "ipx_db_connections_max", "ipx_worker_queue", "ipx_runtime_tasks"]
|
||||||
|
{
|
||||||
assert!(text.contains(name), "{name}");
|
assert!(text.contains(name), "{name}");
|
||||||
}
|
}
|
||||||
assert!(metrics::spawn(&at.to_string(), metrics::Sources {
|
assert!(
|
||||||
ctx: ctx.clone(), cmds: mpsc::channel(1).0, events: broadcast::channel(1).0, playing: Default::default(),
|
metrics::spawn(
|
||||||
runtime: tokio::runtime::Handle::current(), started: std::time::Instant::now(),
|
&at.to_string(),
|
||||||
}).is_err(), "a port already taken is an error at start");
|
metrics::Sources {
|
||||||
|
ctx: ctx.clone(),
|
||||||
|
cmds: mpsc::channel(1).0,
|
||||||
|
events: broadcast::channel(1).0,
|
||||||
|
playing: Default::default(),
|
||||||
|
runtime: tokio::runtime::Handle::current(),
|
||||||
|
started: std::time::Instant::now(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.is_err(),
|
||||||
|
"a port already taken is an error at start"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn dead_feeds_leave_the_directory_once_a_day_not_before_every_scan() {
|
async fn dead_feeds_leave_the_directory_once_a_day_not_before_every_scan() {
|
||||||
let ctx = test_ctx(config::Config::default()).await;
|
let ctx = test_ctx(config::Config::default()).await;
|
||||||
let dead = |id: &str| format!("INSERT INTO feeds (id, url, error_since, managed) VALUES ('{id}', 'u-{id}', 100, false)");
|
let dead = |id: &str| {
|
||||||
|
format!("INSERT INTO feeds (id, url, error_since, managed) VALUES ('{id}', 'u-{id}', 100, false)")
|
||||||
|
};
|
||||||
let listed = async || ctx.db.feed_urls().await.unwrap().into_iter().map(|(id, _)| id).collect::<Vec<_>>();
|
let listed = async || ctx.db.feed_urls().await.unwrap().into_iter().map(|(id, _)| id).collect::<Vec<_>>();
|
||||||
ctx.db.sql(&dead("a")).await.unwrap();
|
ctx.db.sql(&dead("a")).await.unwrap();
|
||||||
clean_directory(&ctx).await.unwrap();
|
clean_directory(&ctx).await.unwrap();
|
||||||
@@ -2768,8 +2799,13 @@ mod tests {
|
|||||||
] {
|
] {
|
||||||
db.sql(sql).await.unwrap();
|
db.sql(sql).await.unwrap();
|
||||||
}
|
}
|
||||||
db.store_plans(7, r#"{"300": {"cuts": [[60000,120000],[200000,260000]], "pieces": []},
|
db.store_plans(
|
||||||
"500": {"cuts": [[60000,100000]], "pieces": []}}"#).await.unwrap();
|
7,
|
||||||
|
r#"{"300": {"cuts": [[60000,120000],[200000,260000]], "pieces": []},
|
||||||
|
"500": {"cuts": [[60000,100000]], "pieces": []}}"#,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
// One file on disk, three listeners at once: DeadAir at the usual pause, at a longer
|
// One file on disk, three listeners at once: DeadAir at the usual pause, at a longer
|
||||||
// one, and the original.
|
// one, and the original.
|
||||||
let (usual, long, plain) = (
|
let (usual, long, plain) = (
|
||||||
@@ -2916,7 +2952,9 @@ mod tests {
|
|||||||
let got = feed::Fetched::Body { bytes: body, etag: None, last_modified: None };
|
let got = feed::Fetched::Body { bytes: body, etag: None, last_modified: None };
|
||||||
let icon = "https://example.com/favicon.ico".to_string();
|
let icon = "https://example.com/favicon.ico".to_string();
|
||||||
let cfg = config::Feed { url: "http://127.0.0.1:9/feed".into(), ..feed() };
|
let cfg = config::Feed { url: "http://127.0.0.1:9/feed".into(), ..feed() };
|
||||||
scan_one(&ctx, "iconic", &cfg, &db::HttpState::default(), true, Some((Ok((got, None)), Some(icon.clone())))).await.unwrap();
|
scan_one(&ctx, "iconic", &cfg, &db::HttpState::default(), true, Some((Ok((got, None)), Some(icon.clone()))))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(ctx.db.feed_summary("iconic").await.unwrap().image, Some(icon));
|
assert_eq!(ctx.db.feed_summary("iconic").await.unwrap().image, Some(icon));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2963,6 +3001,46 @@ mod tests {
|
|||||||
assert!(ctx.db.feed_summary("has-file").await.unwrap().orphaned, "and flagged as orphaned");
|
assert!(ctx.db.feed_summary("has-file").await.unwrap().orphaned, "and flagged as orphaned");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_kept_feed_outlives_its_opml_for_whoever_kept_it() {
|
||||||
|
let mut cfg = config::Config::default();
|
||||||
|
cfg.feeds.insert("opml".into(), feed());
|
||||||
|
let ctx = Arc::new(test_ctx(cfg).await);
|
||||||
|
let (me, you) = (
|
||||||
|
ctx.db.create_user("me", None, false).await.unwrap(),
|
||||||
|
ctx.db.create_user("you", None, false).await.unwrap(),
|
||||||
|
);
|
||||||
|
for id in ["opml", "a", "b"] {
|
||||||
|
if id != "opml" {
|
||||||
|
ctx.db.upsert_managed(id, &format!("http://x/{id}.xml"), id, "opml").await.unwrap();
|
||||||
|
}
|
||||||
|
for u in [me, you] {
|
||||||
|
ctx.db.subscribe(u, id).await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(ctx.db.set_kept(me, "a", true).await.unwrap());
|
||||||
|
assert!(ctx.db.set_kept(me, "b", true).await.unwrap());
|
||||||
|
|
||||||
|
// The OPML drops a: mine to keep, a feed of its own; yours, unkept, goes.
|
||||||
|
let parent = ctx.cfg().feeds["opml"].clone();
|
||||||
|
sync_group(&ctx, "opml", &parent, &[("b".into(), "http://x/b.xml".into())]).await.unwrap();
|
||||||
|
let a = ctx.cfg().feeds.get("a").cloned().expect("a is in the catalogue");
|
||||||
|
assert_eq!((a.url.as_str(), a.group), ("http://x/a.xml", None));
|
||||||
|
assert!(!ctx.db.managed_feeds().await.unwrap().iter().any(|m| m.id == "a"));
|
||||||
|
assert!(ctx.db.subscription(me, "a").await.unwrap().is_some());
|
||||||
|
assert!(ctx.db.subscription(you, "a").await.unwrap().is_none());
|
||||||
|
|
||||||
|
// Leaving the OPML takes what I did not keep and leaves b, still the OPML's while you read it.
|
||||||
|
leave(&ctx, me, "opml").await.unwrap();
|
||||||
|
assert!(ctx.db.subscription(me, "b").await.unwrap().is_some());
|
||||||
|
assert!(ctx.db.managed_feeds().await.unwrap().iter().any(|m| m.id == "b"));
|
||||||
|
// Once nobody reads it, b is mine alone, and yours goes with the OPML.
|
||||||
|
leave(&ctx, you, "opml").await.unwrap();
|
||||||
|
assert!(ctx.cfg().feeds.get("b").is_some_and(|f| f.group.is_none()));
|
||||||
|
assert!(ctx.db.subscription(me, "b").await.unwrap().is_some());
|
||||||
|
assert!(ctx.db.subscription(you, "b").await.unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_stranded_group_is_retired_but_a_promoted_feed_keeps_its_entries() {
|
async fn a_stranded_group_is_retired_but_a_promoted_feed_keeps_its_entries() {
|
||||||
// davewiner: the OPML left config before retire_group existed, and 11 of its feeds
|
// davewiner: the OPML left config before retire_group existed, and 11 of its feeds
|
||||||
|
|||||||
@@ -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,7 +160,8 @@ 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()
|
||||||
|
.route(
|
||||||
"/metrics",
|
"/metrics",
|
||||||
axum::routing::get(move || {
|
axum::routing::get(move || {
|
||||||
let s = s.clone();
|
let s = s.clone();
|
||||||
@@ -155,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)]);
|
||||||
@@ -178,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
|
||||||
@@ -193,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),
|
||||||
@@ -233,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);
|
||||||
@@ -278,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");
|
||||||
|
|||||||
@@ -5,23 +5,40 @@
|
|||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
|
||||||
|
use futures_util::StreamExt as _;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tracing::Instrument;
|
use tracing::Instrument;
|
||||||
|
|
||||||
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
|
|
||||||
use crate::Ctx;
|
use crate::Ctx;
|
||||||
use crate::db::{Ask, Unjudged};
|
use crate::db::{Ask, Unjudged, step};
|
||||||
use crate::feed;
|
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
|
/// 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.
|
/// long the queue is held before people who got a key later are looked at.
|
||||||
const BATCH: i64 = 50;
|
const BATCH: i64 = 50;
|
||||||
/// Between passes when nothing wakes it: a scan or a new Signal does, so this is a backstop.
|
/// 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);
|
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.
|
/// How sure Jev has to be that an item is about the keywords.
|
||||||
const THRESHOLD: f64 = 0.5;
|
const THRESHOLD: f64 = 0.5;
|
||||||
/// Of an item's text, which is a podcast's whole show notes at times.
|
/// Of an item's text, which is a podcast's whole show notes at times. 2000 until #275: on 55 real
|
||||||
const TEXT_CHARS: usize = 2000;
|
/// 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>) {
|
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||||
loop {
|
loop {
|
||||||
@@ -29,6 +46,7 @@ pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
|||||||
_ = stop.changed() => return,
|
_ = stop.changed() => return,
|
||||||
done = pass(&ctx) => done,
|
done = pass(&ctx) => done,
|
||||||
};
|
};
|
||||||
|
crate::metrics::STATS.signals_last_pass.store(crate::db::now(), Relaxed);
|
||||||
match done {
|
match done {
|
||||||
Ok(true) => continue,
|
Ok(true) => continue,
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
@@ -42,62 +60,149 @@ pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// One batch. True when there may be more waiting.
|
||||||
async fn pass(ctx: &Ctx) -> Result<bool> {
|
async fn pass(ctx: &Ctx) -> Result<bool> {
|
||||||
let skip: Vec<i64> = ctx.signals_refused.lock().unwrap().iter().copied().collect();
|
let server = server_key(ctx);
|
||||||
let items = ctx.db.unjudged(&skip, BATCH).await?;
|
// 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() {
|
if items.is_empty() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
let span = tracing::info_span!("signals", entries = items.len());
|
let span = tracing::info_span!(
|
||||||
|
"signals",
|
||||||
|
entries = items.len(),
|
||||||
|
calls = tracing::field::Empty,
|
||||||
|
judged = tracing::field::Empty,
|
||||||
|
hits = tracing::field::Empty
|
||||||
|
);
|
||||||
async {
|
async {
|
||||||
let mut refused = HashSet::new();
|
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 hits_for = std::collections::BTreeMap::<i64, usize>::new();
|
||||||
let mut stopped = false;
|
let mut judged = 0;
|
||||||
for item in &items {
|
// Each call starts only when a place is free, so one that sees a key refused or the pass
|
||||||
if refused.contains(&item.user_id) {
|
// stopped is not sent; those already in flight are let finish, and their answers kept.
|
||||||
continue;
|
// 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;
|
||||||
}
|
}
|
||||||
match judge(ctx, item).await {
|
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) => {
|
Ok(verdicts) => {
|
||||||
|
for (item, verdicts) in group.iter().zip(verdicts) {
|
||||||
|
judged += 1;
|
||||||
let hits = verdicts.iter().filter(|(_, h)| *h).count();
|
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 {
|
if hits > 0 {
|
||||||
*hits_for.entry(item.user_id).or_default() += hits;
|
*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");
|
tracing::info!(ev = "signal", user = item.user_id, feed = %item.feed_id, new = hits, "an item fits a Signal");
|
||||||
}
|
}
|
||||||
ctx.db.record_verdicts(&item.feed_id, &item.guid, &verdicts).await?;
|
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
|
// 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.
|
// again once its owner saves one (`web::patch_me`) or the daemon restarts.
|
||||||
Err(e) if refuses_key(&e) => {
|
Err(e) if refuses_key(&e) => {
|
||||||
tracing::warn!(user = item.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key");
|
if refused.lock().unwrap().insert(first.user_id) {
|
||||||
refused.insert(item.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:
|
// 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).
|
// the rest wait for the next pass rather than each failing the same way (#257).
|
||||||
Err(e) if malformed(&e) => {
|
Err(e) if malformed(&e) => {
|
||||||
tracing::warn!(user = item.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signals request as malformed");
|
if !stopped.swap(true, Relaxed) {
|
||||||
stopped = true;
|
tracing::warn!(user = first.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signals request as malformed");
|
||||||
break;
|
}
|
||||||
|
}
|
||||||
|
// 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.
|
// Left unjudged, so it is asked again next pass.
|
||||||
Err(e) => tracing::warn!(user = item.user_id, feed = %item.feed_id, error = %format!("{e:#}"), "Jev could not judge an item"),
|
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.
|
// Once a pass, not once an item: a page fetches its counts again on each.
|
||||||
for (user, hits) in hits_for {
|
for (user, hits) in hits_for {
|
||||||
ctx.out.emit(crate::ipc::Event::SignalHits { user, hits });
|
ctx.out.emit(crate::ipc::Event::SignalHits { user, hits });
|
||||||
}
|
}
|
||||||
|
let refused = refused.into_inner().unwrap();
|
||||||
let some_refused = !refused.is_empty();
|
let some_refused = !refused.is_empty();
|
||||||
ctx.signals_refused.lock().unwrap().extend(refused);
|
ctx.signals_refused.lock().unwrap().extend(refused);
|
||||||
// A pass that only failed would otherwise go straight round again on the same items, and
|
// 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.
|
// one TypeSafe refused as malformed would ask again at once, however full.
|
||||||
anyhow::Ok(!stopped && (full(&items) || some_refused))
|
anyhow::Ok(!stopped.into_inner() && (full(&items) || some_refused))
|
||||||
}
|
}
|
||||||
.instrument(span)
|
.instrument(span)
|
||||||
.await
|
.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
|
/// A call TypeSafe answered with an error, and what it said: its body says why, where the status
|
||||||
/// alone said only 422 (#257).
|
/// alone said only 422 (#257).
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -125,51 +230,90 @@ fn full(items: &[Unjudged]) -> bool {
|
|||||||
items.iter().map(|i| i.signals.len()).sum::<usize>() as i64 >= BATCH
|
items.iter().map(|i| i.signals.len()).sum::<usize>() as i64 >= BATCH
|
||||||
}
|
}
|
||||||
|
|
||||||
fn refuses_key(e: &anyhow::Error) -> bool {
|
pub fn refuses_key(e: &anyhow::Error) -> bool {
|
||||||
matches!(status(e), Some(401 | 402 | 403))
|
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 {
|
fn malformed(e: &anyhow::Error) -> bool {
|
||||||
matches!(status(e), Some(400 | 422))
|
matches!(status(e), Some(400 | 422))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn judge(ctx: &Ctx, item: &Unjudged) -> Result<Vec<(i64, bool)>> {
|
/// One call about one item, for everyone in `group`: their verdicts, in the group's order.
|
||||||
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
|
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!(
|
let span = tracing::info_span!(
|
||||||
"jev",
|
"jev",
|
||||||
user = item.user_id,
|
user = group[0].user_id,
|
||||||
signal_count = item.signals.len(),
|
people = group.len(),
|
||||||
|
signal_count = asks.len(),
|
||||||
"http.response.status_code" = tracing::field::Empty,
|
"http.response.status_code" = tracing::field::Empty,
|
||||||
error = 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: serde_json::Value = async {
|
let answer = called(span, ask(ctx, key, &request(group[0], &asks))).await?;
|
||||||
let r = ctx.client.post(url).bearer_auth(&item.key).json(&request(item)).timeout(feed::FEED_TIMEOUT).send().await?;
|
// A shared call is billed by each person's share of its questions, which are most of it.
|
||||||
let status = r.status().as_u16();
|
let [input, output] = usage(&answer);
|
||||||
tracing::Span::current().record("http.response.status_code", status);
|
for item in group {
|
||||||
if !r.status().is_success() {
|
let share = item.signals.len() as f64 / asks.len() as f64;
|
||||||
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
|
STATS.bill(&item.user_name, paid, [0.0, 0.0, input * share, output * share]);
|
||||||
tracing::Span::current().record("error", body.as_str());
|
|
||||||
return Err(Refused { status, body }.into());
|
|
||||||
}
|
}
|
||||||
anyhow::Ok(r.json().await?)
|
|
||||||
}
|
|
||||||
.instrument(span)
|
|
||||||
.await?;
|
|
||||||
// A question Jev left unanswered stays unjudged and is asked again.
|
// A question Jev left unanswered stays unjudged and is asked again.
|
||||||
Ok(item.signals.iter().filter_map(|a| hit(&answer, a.id).map(|h| (a.id, h))).collect())
|
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
|
/// 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.
|
/// title in it sent a null. One yes-or-no question for each Signal, keyed by its id.
|
||||||
fn request(item: &Unjudged) -> serde_json::Value {
|
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 text: String = text_of(item.description.as_deref().unwrap_or("")).chars().take(TEXT_CHARS).collect();
|
||||||
let state = format!(
|
let state = format!(
|
||||||
"Feed: {}\nTitle: {}\n\n{text}",
|
"Feed: {}\nTitle: {}\n\n{text}",
|
||||||
item.feed_title.as_deref().unwrap_or(""),
|
item.feed_title.as_deref().unwrap_or(""),
|
||||||
item.title.as_deref().unwrap_or("")
|
item.title.as_deref().unwrap_or("")
|
||||||
);
|
);
|
||||||
let questions: serde_json::Map<String, serde_json::Value> =
|
let questions: serde_json::Map<String, serde_json::Value> = asks.iter().map(|a| (key(a.id), question(a))).collect();
|
||||||
item.signals.iter().map(|a| (key(a.id), question(a))).collect();
|
|
||||||
serde_json::json!({ "model": "jev-latest", "state": state, "questions": questions })
|
serde_json::json!({ "model": "jev-latest", "state": state, "questions": questions })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +327,11 @@ fn key(id: i64) -> String {
|
|||||||
/// 0.02, with AI code review still 0.94 (#259). `criteria` is an object of what true and false
|
/// 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).
|
/// mean: a sentence on its own was refused with a 422 (#257).
|
||||||
fn question(a: &Ask) -> serde_json::Value {
|
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(", "));
|
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 {
|
if let Some(d) = &a.description {
|
||||||
said.push_str(&format!(" In their own words: {d}"));
|
said.push_str(&format!(" In their own words: {d}"));
|
||||||
}
|
}
|
||||||
@@ -206,25 +354,124 @@ fn hit(answer: &serde_json::Value, id: i64) -> Option<bool> {
|
|||||||
/// chooses among options and cannot name one, so the page searches Font Awesome, whose index is
|
/// 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
|
/// 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.
|
/// server's page doing it, once a Signal.
|
||||||
pub async fn pick_icon(ctx: &Ctx, key: &str, name: &str, words: &[String], description: Option<&str>, candidates: &[(String, String)]) -> Result<String> {
|
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 url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
|
||||||
let span = tracing::info_span!("jev_icon", candidates = candidates.len(), "http.response.status_code" = tracing::field::Empty);
|
let mut req = ctx.client.post(url).bearer_auth(key).json(body).timeout(feed::FEED_TIMEOUT);
|
||||||
async {
|
if let Some(tp) = ipx_directory::otel::traceparent() {
|
||||||
let r = ctx.client.post(url).bearer_auth(key).json(&icon_request(name, words, description, candidates)).timeout(feed::FEED_TIMEOUT).send().await?;
|
req = req.header("traceparent", tp);
|
||||||
|
}
|
||||||
|
pace(body).await;
|
||||||
|
let r = req.send().await?;
|
||||||
let status = r.status().as_u16();
|
let status = r.status().as_u16();
|
||||||
tracing::Span::current().record("http.response.status_code", status);
|
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() {
|
if !r.status().is_success() {
|
||||||
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
|
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
|
||||||
return Err(Refused { status, body }.into());
|
return Err(Refused { status, body }.into());
|
||||||
}
|
}
|
||||||
let answer: serde_json::Value = r.json().await?;
|
let answer: serde_json::Value = r.json().await?;
|
||||||
chosen(&answer, candidates).ok_or_else(|| anyhow::anyhow!("Jev chose no icon offered: {answer}"))
|
// 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);
|
||||||
}
|
}
|
||||||
.instrument(span)
|
}
|
||||||
.await
|
Ok(answer)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn icon_request(name: &str, words: &[String], description: Option<&str>, candidates: &[(String, String)]) -> serde_json::Value {
|
/// 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(", "));
|
let mut state = format!("Signal: {name}\nListens for: {}", words.join(", "));
|
||||||
if let Some(d) = description {
|
if let Some(d) = description {
|
||||||
state.push_str(&format!("\nIn their words: {d}"));
|
state.push_str(&format!("\nIn their words: {d}"));
|
||||||
@@ -307,7 +554,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_icon_is_one_jev_was_offered() {
|
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 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);
|
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"]["type"], "choice");
|
||||||
assert_eq!(r["questions"]["icon"]["criteria"]["solid/robot"], "Robot: ai, bot");
|
assert_eq!(r["questions"]["icon"]["criteria"]["solid/robot"], "Robot: ai, bot");
|
||||||
@@ -327,13 +577,76 @@ mod tests {
|
|||||||
assert_eq!(text_of(r#"<img src="x.png" alt="">"#), "");
|
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]
|
#[test]
|
||||||
fn a_batch_is_full_by_its_rows_not_its_items() {
|
fn a_batch_is_full_by_its_rows_not_its_items() {
|
||||||
let item = |n: usize| Unjudged {
|
let item = |n: usize| Unjudged {
|
||||||
user_id: 1, key: "k".into(), feed_id: "f".into(), guid: "g".into(), feed_title: None, title: None, description: None,
|
user_id: 1,
|
||||||
signals: (0..n as i64).map(|id| Ask { id, name: "s".into(), keywords: vec![], description: None }).collect(),
|
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..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<_>>()));
|
assert!(!full(&(0..24).map(|_| item(2)).collect::<Vec<_>>()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +654,8 @@ mod tests {
|
|||||||
fn each_signal_is_its_own_question_on_the_items_words() {
|
fn each_signal_is_its_own_question_on_the_items_words() {
|
||||||
let item = Unjudged {
|
let item = Unjudged {
|
||||||
user_id: 1,
|
user_id: 1,
|
||||||
key: "k".into(),
|
user_name: "a".into(),
|
||||||
|
key: Some("k".into()),
|
||||||
feed_id: "f".into(),
|
feed_id: "f".into(),
|
||||||
guid: "g".into(),
|
guid: "g".into(),
|
||||||
feed_title: Some("Pit Lane".into()),
|
feed_title: Some("Pit Lane".into()),
|
||||||
@@ -349,17 +663,33 @@ mod tests {
|
|||||||
description: Some("<p>Qualifying <b>report</b></p>".into()),
|
description: Some("<p>Qualifying <b>report</b></p>".into()),
|
||||||
signals: vec![
|
signals: vec![
|
||||||
Ask { id: 3, name: "F1".into(), keywords: vec!["formula 1".into(), "f1".into()], description: None },
|
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()) },
|
Ask {
|
||||||
|
id: 4,
|
||||||
|
name: "Food".into(),
|
||||||
|
keywords: vec!["cooking".into()],
|
||||||
|
description: Some("Recipes, not restaurants.".into()),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
let r = request(&item);
|
let r = request(&item, &item.signals.iter().collect::<Vec<_>>());
|
||||||
assert_eq!(r["state"], "Feed: Pit Lane\nTitle: Monaco\n\nQualifying report");
|
assert_eq!(r["state"], "Feed: Pit Lane\nTitle: Monaco\n\nQualifying report");
|
||||||
let q = &r["questions"]["signal_3"];
|
let q = &r["questions"]["signal_3"];
|
||||||
assert_eq!(q["type"], "noul");
|
assert_eq!(q["type"], "noul");
|
||||||
let says = q["instructions"].as_str().unwrap();
|
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("\"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!(!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!(
|
||||||
assert!(r["questions"]["signal_4"]["instructions"].as_str().unwrap().contains("In their own words: Recipes, not restaurants."));
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
34
deny.toml
Normal file
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"
|
||||||
@@ -165,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
|
||||||
|
|
||||||
|
|||||||
105
docs/signals.md
105
docs/signals.md
@@ -6,13 +6,21 @@ mean (#259). TypeSafe's Jev reads each item of the feeds they subscribe to and p
|
|||||||
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
|
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.
|
own feed. The rest stays noise, where it was.
|
||||||
|
|
||||||
Each person brings their own TypeSafe key, entered in Settings. Nothing is judged for someone
|
Whose TypeSafe key pays for Jev is the admin's choice (#271), under Admin, Server, **Signals for
|
||||||
without one. Jev is asked once for each item, on that person's key, with one question for each of
|
everyone, on this server's TypeSafe key** (`signals_on_server`):
|
||||||
their Signals that has not heard about the item yet.
|
|
||||||
|
- **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
|
## What someone sees
|
||||||
|
|
||||||
- **The sidebar.** A Signals folder comes first in the feed list, under All Subscriptions (#255,
|
- **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
|
#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
|
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.
|
person's Signals, each with its items and unread counted under its name.
|
||||||
@@ -22,15 +30,19 @@ their Signals that has not heard about the item yet.
|
|||||||
**Auto** asks Jev to choose (#263), and a Signal made without an icon gets Jev's pick by
|
**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.
|
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.
|
- **Each Signal's row** says "12 items · 3 unread", as a feed's row counts its own.
|
||||||
- **The folder's page** lists the Signals, as an OPML's page lists its feeds, with the + for a new one.
|
- **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.
|
- **A Signal's page** is an ordinary item list: All, Unread, Downloaded and Pinned, search and sort.
|
||||||
Its header says what it listens for (the sentence, or the words), and that nothing is picked
|
Its header says what it listens for (the sentence, or the words), and, on people's own keys,
|
||||||
out without a key. Edit changes the name, the words or the sentence; Delete removes the Signal
|
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.
|
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
|
- **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).
|
round. The counts follow without a reload (#258).
|
||||||
- **Settings, TypeSafe key.** A password field. A key saves on leaving the field, and the bin
|
- **Settings, Signals.** On the server's key, a line saying the server pays. On people's own, a
|
||||||
forgets it. The key is never sent back to the page, which only learns whether one is set.
|
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
|
## How an item gets into a Signal
|
||||||
|
|
||||||
@@ -65,24 +77,39 @@ scan worker, so a slow TypeSafe never holds up a scan.
|
|||||||
`Db::unjudged` finds, newest first, up to 50 items that are:
|
`Db::unjudged` finds, newest first, up to 50 items that are:
|
||||||
|
|
||||||
- in a feed the Signal's owner subscribes to,
|
- in a feed the Signal's owner subscribes to,
|
||||||
- published (or, with no date, first seen) in the last 30 days (`SIGNAL_DAYS`),
|
|
||||||
- not hidden from them by their block lists,
|
- not hidden from them by their block lists,
|
||||||
- not yet in `signal_verdicts` for that Signal,
|
- for someone with a key that TypeSafe has not refused since the daemon started,
|
||||||
- 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.
|
||||||
|
|
||||||
So a new Signal looks back a month, and after that sees each new item. A show subscribed to today
|
The window is the admin's, Admin, Server, **Signals look back (days)** (`signal_days`, 7 by
|
||||||
has its back catalogue skipped, apart from its last month.
|
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
|
Rows for one item and one person are grouped, so an item is one call whatever the number of
|
||||||
Signals. A full batch means there may be more, and the next pass starts at once. Otherwise the
|
Signals. On the server's key, everyone's Signals on the same item go in one call too, its text
|
||||||
judge sleeps until woken or for 5 minutes.
|
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
|
### Every answer is kept
|
||||||
|
|
||||||
`signal_verdicts` records each answer, fitting or not. An item with no row has not been asked yet;
|
`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.
|
an item with a row is never asked again. So the cost is one call per item per person, once.
|
||||||
Changing a Signal's name, words or sentence deletes its verdicts, and everything in the window is
|
Changing a Signal's name, words or sentence marks what it picked out to be asked again
|
||||||
asked again. The first start with sentences (#259) deleted every verdict, since they answered the
|
(`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.
|
old question.
|
||||||
|
|
||||||
## TypeSafe
|
## TypeSafe
|
||||||
@@ -94,7 +121,7 @@ Questions in one call are judged independently and in parallel.
|
|||||||
|
|
||||||
### A request
|
### A request
|
||||||
|
|
||||||
Sent with `Authorization: Bearer <the person's key>`, for an item and two of their Signals:
|
Sent with `Authorization: Bearer <the key>`, the server's or the person's, for an item and two of their Signals:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -122,7 +149,9 @@ Sent with `Authorization: Bearer <the person's key>`, for an item and two of the
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `state` is text: the feed's title, the item's title, and its show notes with the HTML taken out,
|
- `state` is text: the feed's title, the item's title, and its show notes with the HTML taken out,
|
||||||
cut to 2,000 characters (`TEXT_CHARS`).
|
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.
|
- 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
|
- `criteria` is an object saying what true and false mean. A sentence there is refused with a 422
|
||||||
(#257).
|
(#257).
|
||||||
@@ -155,8 +184,10 @@ missing from the answer is left unjudged and asked again next pass.
|
|||||||
|
|
||||||
| TypeSafe answers | What the judge does |
|
| TypeSafe answers | What the judge does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 401, 402, 403 | The key is refused. That person is passed over until they save a key or the daemon restarts, so a bad key is not sent the whole queue. Logged once: "TypeSafe refused a Signal key". |
|
| 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. |
|
| 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". |
|
| 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:
|
The error keeps TypeSafe's reply, cut to 500 characters, for example:
|
||||||
@@ -238,9 +269,18 @@ anywhere else.
|
|||||||
{ "typesafe_key": "ts_…" }
|
{ "typesafe_key": "ts_…" }
|
||||||
```
|
```
|
||||||
|
|
||||||
A string saves the key and lets the judge try it again. `null` or an empty string forgets it. A
|
A string is checked with TypeSafe, by one short question on that key, then saved, and the judge
|
||||||
key is up to 500 printable characters. `GET /api/me` says `"typesafe_key_set": true` and nothing
|
tries it again. `null` or an empty string forgets it. A key is up to 500 printable characters.
|
||||||
more.
|
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`
|
### `POST /api/signals/icon`
|
||||||
|
|
||||||
@@ -351,10 +391,15 @@ erDiagram
|
|||||||
- **Refused keys are remembered in memory**, so a restart tries each once again.
|
- **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
|
- **Keywords changed while an item is being judged** can leave that one item's verdict from the old
|
||||||
words.
|
words.
|
||||||
- **There is no meter** of how many calls a key has made. It is one call per item in the person's
|
- **The meter is the server's**, `ipx_jev_tokens_total` and `ipx_jev_requests_total` on
|
||||||
feeds in the window: a person with 1,500 feeds busy enough to publish 3,000 items a month spends
|
`/metrics`, and the Signals row of the dashboard: tokens as TypeSafe's answers count them, by
|
||||||
about 3,000 calls the first month and then as many as come in.
|
person (`user`, the account's name) and whose key paid (`key`, `server` or `own`; #278), with
|
||||||
- **Only the last 30 days** are ever judged, by publish date.
|
`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
|
## Testing
|
||||||
|
|
||||||
@@ -365,4 +410,6 @@ erDiagram
|
|||||||
- `npx playwright test -g Signal`: makes a Signal from the folder's +, and checks that the matching
|
- `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
|
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
|
by the fixture server's `/jev` (`IPX_TYPESAFE_URL`), which refuses the shape TypeSafe refuses, and
|
||||||
`/signal.xml` is dated today, since only the last 30 days are judged.
|
`/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).
|
||||||
|
|||||||
@@ -254,6 +254,68 @@ 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
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ipx-web",
|
"name": "ipx-web",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ipx-web",
|
"name": "ipx-web",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "7.3.1",
|
"@fortawesome/fontawesome-free": "7.3.1",
|
||||||
"@swc/core": "^1.16.2",
|
"@swc/core": "^1.16.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ipx-web",
|
"name": "ipx-web",
|
||||||
"version": "1.1.0",
|
"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": {
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ 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',
|
IPX_TYPESAFE_URL: 'http://127.0.0.1:8792/jev',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
4
rustfmt.toml
Normal file
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
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', '']] },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
BIN
site/shots/signals.webp
Normal file
BIN
site/shots/signals.webp
Normal file
Binary file not shown.
|
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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -144,6 +144,18 @@ test('a Patreon feed is told by its address', () => {
|
|||||||
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', () => {
|
test('a Signal is a place of its own, found by its id', () => {
|
||||||
run(`S.signals = [{id: 7, name: 'F1', keywords: ['f1'], unread: 2}]`);
|
run(`S.signals = [{id: 7, name: 'F1', keywords: ['f1'], unread: 2}]`);
|
||||||
assert.equal(run(`signalId(':signal:7')`), 7);
|
assert.equal(run(`signalId(':signal:7')`), 7);
|
||||||
@@ -199,3 +211,12 @@ test('Jev is offered the icons found for each word of a Signal', () => {
|
|||||||
assert.deepEqual(offered('Apple'), ['brands/apple', 'solid/apple-whole']);
|
assert.deepEqual(offered('Apple'), ['brands/apple', 'solid/apple-whole']);
|
||||||
assert.equal(run(`signalCounts({items: 12, unread: 1})`), '12 items · 1 unread');
|
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}`);
|
||||||
@@ -415,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 }) => {
|
||||||
@@ -450,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 }) => {
|
||||||
@@ -1143,7 +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(),
|
||||||
async () => { await page.locator('#feedlist .sigs').click(); await page.locator('#content .acts [data-a="new"]').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();
|
||||||
@@ -1855,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 () => {
|
||||||
@@ -2073,7 +2185,7 @@ test('the page comes compressed, and the compressed copies cannot be asked for b
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a Signal holds the items Jev says fit its keywords, read where they are read', async ({ page }) => {
|
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.
|
// #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);
|
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();
|
const { id: feed } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/signal.xml' } })).json();
|
||||||
@@ -2116,7 +2228,7 @@ test('a Signal holds the items Jev says fit its keywords, read where they are re
|
|||||||
await page.request.patch('/api/me', { data: { typesafe_key: null } });
|
await page.request.patch('/api/me', { data: { typesafe_key: null } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a Signal with no key to judge it links to where one is got and where it goes', async ({ page }) => {
|
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 } });
|
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();
|
const { id } = await (await page.request.post('/api/signals', { data: { name: 'Keyless', keywords: ['x'] } })).json();
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -2127,3 +2239,55 @@ test('a Signal with no key to judge it links to where one is got and where it go
|
|||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await page.request.delete(`/api/signals/${id}`);
|
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
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>
|
||||||
@@ -13,6 +13,11 @@ http.createServer((req, res) => {
|
|||||||
let body = '';
|
let body = '';
|
||||||
req.on('data', c => { body += c; });
|
req.on('data', c => { body += c; });
|
||||||
req.on('end', () => {
|
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);
|
const { state, questions } = JSON.parse(body);
|
||||||
if (Object.values(questions).some(q => typeof q.criteria !== 'object')) {
|
if (Object.values(questions).some(q => typeof q.criteria !== 'object')) {
|
||||||
res.writeHead(422, { 'content-type': 'application/json' }).end('{"detail":"criteria should be an object"}');
|
res.writeHead(422, { 'content-type': 'application/json' }).end('{"detail":"criteria should be an object"}');
|
||||||
@@ -21,7 +26,8 @@ http.createServer((req, res) => {
|
|||||||
const title = String(state).toLowerCase();
|
const title = String(state).toLowerCase();
|
||||||
const answers = {};
|
const answers = {};
|
||||||
for (const [id, q] of Object.entries(questions)) {
|
for (const [id, q] of Object.entries(questions)) {
|
||||||
const words = /described by these words: ([^.]*)\./.exec(q.instructions)[1].split(', ');
|
// 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 };
|
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 }));
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ answers }));
|
||||||
|
|||||||
@@ -11,6 +11,30 @@ 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
|
## [1.1.0] - 2026-10-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -62,7 +86,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- On a phone, the Subscribe button on a Directory feed's page is full size again, not squashed to a sliver (#212).
|
- 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.1.0...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.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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
59
web/app.css
59
web/app.css
@@ -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}
|
||||||
@@ -848,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}
|
||||||
@@ -1094,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. */
|
||||||
@@ -1116,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}
|
||||||
@@ -1145,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}
|
||||||
@@ -1164,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. */
|
||||||
@@ -1236,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}
|
||||||
@@ -1344,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. */
|
||||||
@@ -1397,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}
|
||||||
@@ -1412,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%)}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -440,13 +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>
|
||||||
<div class="field"><label for="stskey">TypeSafe key</label>
|
${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"
|
<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'}">
|
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>
|
<button class="btn ico" id="stsforget" title="Forget the key" aria-label="Forget the key"${S.me?.typesafe_key_set?'':' hidden'}>${ICON.trash}</button></div>
|
||||||
<span class="hint">Signals pick what matters out of everything you subscribe to, on your own
|
<span class="hint">Checked with TypeSafe before it is saved, kept on this server and never shown again.</span></div></div>`}
|
||||||
key from <a href="https://typesafe.ai" target="_blank" rel="noopener">typesafe.ai</a>.
|
|
||||||
The key is kept on this server and never shown again.</span></div>
|
|
||||||
<div class="field boxed"><label for="tokname">API tokens</label>
|
<div 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">
|
||||||
@@ -476,6 +481,9 @@ 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)=>{
|
const saveKey=async(typesafe_key: string|null)=>{
|
||||||
try{
|
try{
|
||||||
await api('/api/me',{method:'PATCH',body:JSON.stringify({typesafe_key})});
|
await api('/api/me',{method:'PATCH',body:JSON.stringify({typesafe_key})});
|
||||||
@@ -483,11 +491,21 @@ async function prefsModal(){
|
|||||||
$<HTMLInputElement>('#stskey').value='';
|
$<HTMLInputElement>('#stskey').value='';
|
||||||
$<HTMLInputElement>('#stskey').placeholder=typesafe_key?'Saved. Paste a new one to replace it':'Paste your key';
|
$<HTMLInputElement>('#stskey').placeholder=typesafe_key?'Saved. Paste a new one to replace it':'Paste your key';
|
||||||
$<HTMLButtonElement>('#stsforget').hidden=!typesafe_key;
|
$<HTMLButtonElement>('#stsforget').hidden=!typesafe_key;
|
||||||
toast(typesafe_key?'Saved':'Forgotten');
|
$<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); }
|
}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); };
|
$<HTMLInputElement>('#stskey').onchange=(e: Event)=>{ const k=(e.target as HTMLInputElement).value.trim(); if(k) saveKey(k); };
|
||||||
$<HTMLButtonElement>('#stsforget').onclick=()=>saveKey(null);
|
$<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{
|
||||||
@@ -571,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])=>
|
||||||
@@ -588,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')}${
|
||||||
|
signalsOn()?place('sinsig','hide_from_signals',`<span class="fsig">${tile('signals')}</span>`,'Signals'):''}</div>
|
||||||
|
<p class="cap">${listHides
|
||||||
|
?`Off where ${esc(listHides.title||listHides.id)} is off; its settings bring them back.`
|
||||||
|
:`Off leaves ${these} out of that list and its count. ${isGroup?'Their':'Its'} own page still has everything.`}</p>
|
||||||
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
|
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
|
||||||
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
|
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
|
||||||
<p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along
|
<p class="cap">Whole words or phrases, hidden everywhere and not downloaded for you.</p></div>`}`)}
|
||||||
with what Settings hides everywhere.</p></div>`}
|
${sect('Directory',`${sw(`<input type="checkbox" role="switch" id="sprivate"${f.private||f.private_by_address?' checked':''}${
|
||||||
<div class="ffield">${sw(`<input type="checkbox" role="switch" id="sprivate"${f.private||f.private_by_address?' checked':''}${
|
|
||||||
f.private_by_address||(f.private&&!admin)?' disabled':''}>`,'Private',' lead')}
|
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');
|
||||||
@@ -649,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();
|
||||||
@@ -698,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();
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ function connect(){
|
|||||||
const preloadSoon=soon(preloadLists,2000);
|
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
|
||||||
@@ -62,7 +64,7 @@ function connect(){
|
|||||||
else if(ev.ev==='feed_done'){
|
else if(ev.ev==='feed_done'){
|
||||||
setScanning(ev.feed,false);
|
setScanning(ev.feed,false);
|
||||||
if(!mine(ev.feed)) return;
|
if(!mine(ev.feed)) return;
|
||||||
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.
|
||||||
|
|||||||
@@ -14,9 +14,8 @@ function renderFeed(){
|
|||||||
const name=f?(f.title||f.id):sg?sg.name: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">
|
||||||
@@ -34,6 +33,7 @@ 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>
|
||||||
@@ -60,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!));
|
||||||
@@ -99,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">
|
||||||
@@ -129,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);
|
||||||
@@ -175,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);
|
||||||
|
|||||||
@@ -35,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>();
|
||||||
@@ -90,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)){
|
||||||
|
|||||||
@@ -24,11 +24,9 @@ function keepList(key: string, r: ListPage){
|
|||||||
/// Fetches a list's first page ahead of a click: when the page has loaded, and when the pointer
|
/// 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.
|
/// reaches a feed or a filter. Not one kept already or on its way.
|
||||||
function prefetch(feed: string, filter=S.filter){
|
function prefetch(feed: string, filter=S.filter){
|
||||||
// Currently Listening and the Directory draw from their own address; a folder's page lists its
|
// Currently Listening and the Directory draw from their own address.
|
||||||
// feeds, not items.
|
|
||||||
const url=VIEWS[feed]?.url;
|
const url=VIEWS[feed]?.url;
|
||||||
if(url) return prefetchPage(url);
|
if(url) return prefetchPage(url);
|
||||||
if(S.feeds.some(c=>c.group===feed)) return;
|
|
||||||
const key=listKey(feed,filter);
|
const key=listKey(feed,filter);
|
||||||
if(lists.has(key)||fetching.has(key)) return;
|
if(lists.has(key)||fetching.has(key)) return;
|
||||||
const got: Promise<ListPage>=api(listUrl(feed,filter));
|
const got: Promise<ListPage>=api(listUrl(feed,filter));
|
||||||
@@ -51,8 +49,9 @@ 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,
|
/// A feed changed (a scan, a read elsewhere): its kept lists, and every one of All Subscriptions,
|
||||||
/// are fetched again when next wanted.
|
/// are fetched again when next wanted.
|
||||||
function forgetLists(feed: string){
|
function forgetLists(feed: string){
|
||||||
// A Signal holds items of any feed.
|
// A Signal holds items of any feed, and an OPML's list its shows' (#283).
|
||||||
for(const k of [...lists.keys()]) if(k.startsWith(feed+'\n')||k.startsWith(':all\n')||k.startsWith(':signal:')) lists.delete(k);
|
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
|
/// 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.
|
/// and the feeds with something unread, most first.
|
||||||
@@ -125,7 +124,9 @@ function listLoading(box=$('#eps')){
|
|||||||
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 ?? ''], sg=signalOf(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:sg?sg.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):sg?sg.name: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;
|
||||||
@@ -375,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();
|
||||||
@@ -400,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'}"
|
||||||
@@ -409,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.
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ function signalOf(view: string|null){
|
|||||||
return id===null ? undefined : S.signals.find(s=>s.id===id);
|
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
|
/// 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.
|
/// is (#255, #256). As rows under the places, a long list of them pushed the feeds down.
|
||||||
const SIGNALS=':signals';
|
const SIGNALS=':signals';
|
||||||
@@ -42,6 +46,7 @@ function signalTile(sg: {name: string, icon: string|null}){
|
|||||||
}
|
}
|
||||||
const badgeHTML=(n: number)=>`<span class="badge${n?'':' zero'}" title="${n} unread">${n>999?'999+':n}</span>`;
|
const badgeHTML=(n: number)=>`<span class="badge${n?'':' zero'}" title="${n} unread">${n>999?'999+':n}</span>`;
|
||||||
function signalFolder(list: HTMLElement, q: string){
|
function signalFolder(list: HTMLElement, q: string){
|
||||||
|
if(!signalsOn()) return;
|
||||||
const mine=S.signals.filter(s=>!q||s.name.toLowerCase().includes(q));
|
const mine=S.signals.filter(s=>!q||s.name.toLowerCase().includes(q));
|
||||||
if(q&&!mine.length) return;
|
if(q&&!mine.length) return;
|
||||||
const open=expanded.has(SIGNALS)||!!q;
|
const open=expanded.has(SIGNALS)||!!q;
|
||||||
@@ -101,7 +106,7 @@ function renderSignals(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function signalHead(sg: Signal){
|
function signalHead(sg: Signal){
|
||||||
const nokey=S.me&&!S.me.typesafe_key_set;
|
const nokey=S.me&&!signalsOn();
|
||||||
return `
|
return `
|
||||||
<div class="fhead slim">
|
<div class="fhead slim">
|
||||||
${signalTile(sg)}
|
${signalTile(sg)}
|
||||||
@@ -130,14 +135,14 @@ async function signalAction(a: string, sg: Signal){
|
|||||||
|
|
||||||
/// Making a Signal, or changing one.
|
/// Making a Signal, or changing one.
|
||||||
function signalModal(sg?: Signal){
|
function signalModal(sg?: Signal){
|
||||||
const nokey=S.me&&!S.me.typesafe_key_set;
|
const nokey=S.me&&!signalsOn();
|
||||||
openModal(`<h3>${sg?'Edit signal':'New signal'}</h3>
|
openModal(`<h3>${sg?'Edit signal':'New signal'}</h3>
|
||||||
<div class="field"><label for="sgname">Name</label>
|
<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"
|
<div class="inline"><button type="button" class="iconpick" id="sgicon" title="Choose an icon" aria-label="Choose an icon"
|
||||||
aria-expanded="false"></button>
|
aria-expanded="false"></button>
|
||||||
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
|
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
|
||||||
<div id="sgpicker" hidden>
|
<div id="sgpicker" hidden>
|
||||||
<div class="inline"><input type="search" id="sgiconq" placeholder="Search icons: robot, car, music…" autocomplete="off">
|
<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="sgjev" title="Pick an icon for me">Auto</button>
|
||||||
<button type="button" class="dlink" id="sgnoicon">Initials</button></div>
|
<button type="button" class="dlink" id="sgnoicon">Initials</button></div>
|
||||||
<div class="icongrid" id="sgicons" role="listbox" aria-label="Icons"></div>
|
<div class="icongrid" id="sgicons" role="listbox" aria-label="Icons"></div>
|
||||||
@@ -146,10 +151,9 @@ function signalModal(sg?: Signal){
|
|||||||
<div class="chips" id="sgkw">${chipsHTML(sg?.keywords||[],'sgkwin')}</div></div>
|
<div class="chips" id="sgkw">${chipsHTML(sg?.keywords||[],'sgkwin')}</div></div>
|
||||||
<div class="field"><label for="sgdesc">In your own words (optional)</label>
|
<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">
|
<input type="text" id="sgdesc" value="${esc(sg?.description||'')}" placeholder="AI tools for writing software: coding agents, vibe coding">
|
||||||
<span class="hint">Every item in the feeds you subscribe to is read, the last 30 days of them
|
<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
|
and each new one, and the ones about this subject are pulled out: its name, these words and
|
||||||
your sentence together. The rest stays noise, in its own feed. Each item is one call on your
|
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>
|
||||||
TypeSafe key.${sg?' A change here reads everything again.':''}</span></div>
|
|
||||||
${nokey?`<p class="hint" style="color:var(--warn)">Without a TypeSafe key, nothing is picked out. ${KEY_LINKS}</p>`:''}
|
${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>
|
<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>`);
|
<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>`);
|
||||||
@@ -280,6 +284,7 @@ const KEY_LINKS=`<a href="https://typesafe.ai" target="_blank" rel="noopener">Ge
|
|||||||
async function keySetting(){
|
async function keySetting(){
|
||||||
closeModal();
|
closeModal();
|
||||||
await prefsModal();
|
await prefsModal();
|
||||||
|
$<HTMLInputElement>('#ssigon').checked=true; $('#ssigkey').hidden=false;
|
||||||
const k=$<HTMLInputElement>('#stskey'); k.scrollIntoView({block:'center'}); k.focus();
|
const k=$<HTMLInputElement>('#stskey'); k.scrollIntoView({block:'center'}); k.focus();
|
||||||
}
|
}
|
||||||
document.addEventListener('click',(e: MouseEvent)=>{
|
document.addEventListener('click',(e: MouseEvent)=>{
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -84,6 +93,10 @@ interface Me {
|
|||||||
version: string;
|
version: string;
|
||||||
/// A TypeSafe key is saved for Signals; the key itself never comes back.
|
/// A TypeSafe key is saved for Signals; the key itself never comes back.
|
||||||
typesafe_key_set: boolean;
|
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).
|
/// A Signal in your sidebar, `/api/signals` (db.rs SignalRow).
|
||||||
|
|||||||
@@ -61,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>';
|
||||||
|
|||||||
Reference in New Issue
Block a user