A terminal reader, ipx; the server is ipxd (#437)

ipx-tui (crates/tui) is iPX's reader in ratatui: your feeds, a feed's
items, an item's text as plain text, read and flagged as on the page,
through the API with an API token. It installs as `ipx`, so the daemon's
binary is now `ipxd`: the Dockerfile, release script, tests and every
doc and message that names its commands follow.

Naming the binary ipxd alone renamed its crate, and with it every log
target (ipx::scan became ipxd::scan): the default filters are ipx=info,
so nearly every line would have been dropped and the dashboard's queries
on the targets gone blank. The daemon's code is now a library still
called ipx, under a five-line ipxd main.rs, and the targets are as they
were.

ratatui is taken without its default features: 32 new packages in place
of 78.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
This commit is contained in:
2026-11-09 11:52:10 +00:00
parent f844cf349a
commit 711b0395f1
40 changed files with 1128 additions and 238 deletions

View File

@@ -70,7 +70,7 @@ Count those from the metrics, not the log.
|---|---|---| |---|---|---|
| `ipx::http` | `method`, `path`, `route`, `status`, `duration_ms` | a warning for each request answered 4xx or 5xx; `route` is the pattern, empty for an unrouted path. Every request, as info, until #364; now those that went well are only in `ipx_http_request_duration_seconds` | | `ipx::http` | `method`, `path`, `route`, `status`, `duration_ms` | a warning for each request answered 4xx or 5xx; `route` is the pattern, empty for an unrouted path. Every request, as info, until #364; now those that went well are only in `ipx_http_request_duration_seconds` |
| `ipx::scan` | `ev` and the event's own: `feed`, `new`, `downloaded`, `failed`, `bytes`, `msg`, `url`, `feeds`, `reason`, `from`, `to`; on a failure `error.type` and, from an HTTP error, `http.response.status_code` | the daemon's events, one line each, in words; warnings are feed and download failures | | `ipx::scan` | `ev` and the event's own: `feed`, `new`, `downloaded`, `failed`, `bytes`, `msg`, `url`, `feeds`, `reason`, `from`, `to`; on a failure `error.type` and, from an HTTP error, `http.response.status_code` | the daemon's events, one line each, in words; warnings are feed and download failures |
| `ipx::io` | `ev`, `feeds`, `pending`, `downloaded` on the `status` reply | commands arriving on the socket (`-> {...}`) and the answer to `ipx status` | | `ipx::io` | `ev`, `feeds`, `pending`, `downloaded` on the `status` reply | commands arriving on the socket (`-> {...}`) and the answer to `ipxd status` |
| `ipx::daemon`, `ipx::cli`, `ipx::catalogue`, `ipx::scan`, ... | message, sometimes fields | start-up, shutdown, account and config messages, under the module that logs them; plain `ipx` before 2026-10-24, when main.rs was split (#356) | | `ipx::daemon`, `ipx::cli`, `ipx::catalogue`, `ipx::scan`, ... | message, sometimes fields | start-up, shutdown, account and config messages, under the module that logs them; plain `ipx` before 2026-10-24, when main.rs was split (#356) |
| `ipx::client` | `ev`, `user`, `feed`, `enclosure`, `deadair`; `msg`, `src`, `line` on an error; `ms` on a timing | what the page reports from the browser (#190): `page_error`, `rejection`, `player_error` and `scroll_stuck` (a drag that should have scrolled the reader and did not, its snapshot in `state`, #384) as warnings; `swipe` (a swipe in the reader: `msg` next, prev, list, end or short, `state` its position, `5/50 of 312, more to load`, #384), `play_start` (ms from play to sound), `stall` (ms waiting mid-play) and `list_shown` (ms from a click to its list drawn, with `feed` the view and `filter`, #246) as info. Lines carry the trace of the action in the page: its API requests send `traceparent`, one trace id per tap or key | | `ipx::client` | `ev`, `user`, `feed`, `enclosure`, `deadair`; `msg`, `src`, `line` on an error; `ms` on a timing | what the page reports from the browser (#190): `page_error`, `rejection`, `player_error` and `scroll_stuck` (a drag that should have scrolled the reader and did not, its snapshot in `state`, #384) as warnings; `swipe` (a swipe in the reader: `msg` next, prev, list, end or short, `state` its position, `5/50 of 312, more to load`, #384), `play_start` (ms from play to sound), `stall` (ms waiting mid-play) and `list_shown` (ms from a click to its list drawn, with `feed` the view and `filter`, #246) as info. Lines carry the trace of the action in the page: its API requests send `traceparent`, one trace id per tap or key |
@@ -78,7 +78,7 @@ Events (`ev`): `feed_start` (debug), `feed_done` (new, downloaded, failed, torre
(not due, debug), `feed_error` (msg), `feed_moved` (from, to: a permanent redirect followed, the address (not due, debug), `feed_error` (msg), `feed_moved` (from, to: a permanent redirect followed, the address
updated), `download_done` (bytes), `download_error` (msg, url), updated), `download_done` (bytes), `download_error` (msg, url),
`torrent_deferred`, `reaped`, `reap_done`, `scan_done` (feeds; debug), `status` (feeds, pending, downloaded: the answer to `torrent_deferred`, `reaped`, `reap_done`, `scan_done` (feeds; debug), `status` (feeds, pending, downloaded: the answer to
`ipx status`, only when someone runs it since #218), `deadair_done` (feed, enclosure, `saved_s` at the `ipxd status`, only when someone runs it since #218), `deadair_done` (feed, enclosure, `saved_s` at the
default pause), `error` (msg). DeadAir also logs `DeadAir found the silences` (`cuts`, `saved_s`, default pause), `error` (msg). DeadAir also logs `DeadAir found the silences` (`cuts`, `saved_s`,
`took_ms`) under `ipx::deadair`, and warns `DeadAir could not read a file` when ffmpeg fails on `took_ms`) under `ipx::deadair`, and warns `DeadAir could not read a file` when ffmpeg fails on
one, which it then passes over until the next restart. one, which it then passes over until the next restart.
@@ -108,7 +108,7 @@ from 2026-10-11 15:15 UTC. Gauges, but for the counters (`_total`) and the one h
| `ipx_process_resident_bytes`, `ipx_process_open_fds` | memory and open files and sockets; mimalloc gives back what is freed, so memory follows use and a rise that never comes down is a leak (#224) | | `ipx_process_resident_bytes`, `ipx_process_open_fds` | memory and open files and sockets; mimalloc gives back what is freed, so memory follows use and a rise that never comes down is a leak (#224) |
| `ipx_feed_fetches_total`, `ipx_feed_bytes_total` | counters: feeds fetched (304s included) and the bytes of their bodies, read whole, so what scans allocate | | `ipx_feed_fetches_total`, `ipx_feed_bytes_total` | counters: feeds fetched (304s included) and the bytes of their bodies, read whole, so what scans allocate |
| `ipx_uptime_seconds`, `ipx_build_info{version}` | since the daemon started; what runs | | `ipx_uptime_seconds`, `ipx_build_info{version}` | since the daemon started; what runs |
| `ipx_worker_busy`, `ipx_worker_job_seconds{kind}` | the worker's job now (`scan`, `reap`, `download`, `status`, `reload`) and for how long; `ipx status` still answers while it is stuck | | `ipx_worker_busy`, `ipx_worker_job_seconds{kind}` | the worker's job now (`scan`, `reap`, `download`, `status`, `reload`) and for how long; `ipxd status` still answers while it is stuck |
| `ipx_jobs_total{kind}`, `ipx_job_seconds_total{kind}` | counters: jobs finished, and their seconds all told | | `ipx_jobs_total{kind}`, `ipx_job_seconds_total{kind}` | counters: jobs finished, and their seconds all told |
| `ipx_last_scan_age_seconds` | since a scan last finished; absent until one has since starting | | `ipx_last_scan_age_seconds` | since a scan last finished; absent until one has since starting |
| `ipx_http_requests_in_flight`, `ipx_scan_feeds_fetching`, `ipx_downloads_active` | work in progress | | `ipx_http_requests_in_flight`, `ipx_scan_feeds_fetching`, `ipx_downloads_active` | work in progress |
@@ -260,7 +260,7 @@ a count says something happened, the lines and traces say why.
reset of the uptime is a restart: match it with a deploy, or it is a crash (check 4). reset of the uptime is a restart: match it with a deploy, or it is a crash (check 4).
The worker: one job over 30 minutes (`ipx_worker_job_seconds`), or a scan last finished over The worker: one job over 30 minutes (`ipx_worker_job_seconds`), or a scan last finished over
15 minutes ago, is the worker stuck, which `ipx status` does not show; its trace and the 15 minutes ago, is the worker stuck, which `ipxd status` does not show; its trace and the
log lines of that time say on what. A kind whose share of `ipx_job_seconds_total` jumps is log lines of that time say on what. A kind whose share of `ipx_job_seconds_total` jumps is
where the worker's time went (reap was most of it until #193). Web requests in flight near 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, and `ipx_db_wait_seconds` for how long. Live updates missed mean a page showed stale the pool's max mean the pool is what they wait on, and `ipx_db_wait_seconds` for how long. Live updates missed mean a page showed stale

View File

@@ -10,7 +10,9 @@ if ! cargo fmt --all --check >/dev/null 2>&1; then
exit 1 exit 1
fi fi
if echo "$staged" | grep -q -e '^Cargo\.toml$' -e '^Cargo\.lock$' -e '^crates/feed/'; then if echo "$staged" | grep -q -e '^Cargo\.toml$' -e '^Cargo\.lock$' -e '^crates/feed/'; then
pkgs="ipx-feed ipx-directory ipx" pkgs="ipx-feed ipx-directory ipx ipx-tui"
elif echo "$staged" | grep -q '^crates/tui/'; then
pkgs="ipx-tui"
elif echo "$staged" | grep -q '^crates/directory/'; then elif echo "$staged" | grep -q '^crates/directory/'; then
pkgs="ipx-directory ipx" pkgs="ipx-directory ipx"
elif echo "$staged" | grep -q '^crates/ipx/'; then elif echo "$staged" | grep -q '^crates/ipx/'; then

View File

@@ -112,7 +112,7 @@ next build makes it again with the new config.
The containers have no Docker healthcheck (#218): Docker runs one with `exec`, which on Tower The containers have no Docker healthcheck (#218): Docker runs one with `exec`, which on Tower
fails whenever root is logged out, because elogind removes `/run/user/0`, where containerd puts fails whenever root is logged out, because elogind removes `/run/user/0`, where containerd puts
each exec's state. Whether ipx is well is Prometheus's question (`up`, the worker's job and time) each exec's state. Whether ipx is well is Prometheus's question (`up`, the worker's job and time)
and `ipx status` against the control socket, which says the daemon answers and can read its and `ipxd status` against the control socket, which says the daemon answers and can read its
database. The socket answers `status` itself instead of queuing it behind the worker's current database. The socket answers `status` itself instead of queuing it behind the worker's current
job, so a worker stuck on one job still answers it. The containers restart on their own after a job, so a worker stuck on one job still answers it. The containers restart on their own after a
reboot. reboot.
@@ -239,7 +239,8 @@ session was stopped. So:
| `crates/ipx/` | `cargo test -p ipx`; the browser tests too if the API changed | | `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/directory/` | `cargo test -p ipx-directory`, then `-p ipx` |
| `crates/feed/` | `cargo test -p ipx-feed`, then `-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 | | `crates/tui/` | `cargo test -p ipx-tui`; it uses only the API, none of the other crates |
| `Cargo.toml`, `Cargo.lock` | all four, one at a time |
The pre-commit hook's clippy follows the same table. The whole browser suite once, at the end 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. of a change to the page or the API; never `cargo test` for the workspace in one command.
@@ -287,7 +288,7 @@ seeking through files, a flood of wrong passwords. Things about them:
127.0.0.1, as production trusts Cloudflare Access's; only the sign-in test uses a password. 127.0.0.1, as production trusts Cloudflare Access's; only the sign-in test uses a password.
* Each threshold is a budget well above what the request takes now: it is there to catch a query * Each threshold is a budget well above what the request takes now: it is there to catch a query
that has started asking once per feed, or writers queueing on a lock, not a slow minute. that has started asking once per feed, or writers queueing on a lock, not a slow minute.
* `ipx status` runs every second throughout, and a run fails if one takes 5s. * `ipxd status` runs every second throughout, and a run fails if one takes 5s.
* One at a time: `node tests/load/run.ts signin`. All four take about six minutes. * One at a time: `node tests/load/run.ts signin`. All four take about six minutes.
* The daemon is on **SQLite** unless `--postgres`, which puts it in `IPX_TEST_DATABASE_URL`, in * The daemon is on **SQLite** unless `--postgres`, which puts it in `IPX_TEST_DATABASE_URL`, in
a schema of its own (`ipx_load`) dropped and made again each run. That URL, in `/src/.envrc`, is a schema of its own (`ipx_load`) dropped and made again each run. That URL, in `/src/.envrc`, is
@@ -377,13 +378,13 @@ ms in ipx, the difference being new connections, 80-330 ms each to open (#241).
go blank without an error; regenerate the dashboard to match. Routine lines (a request that went go blank without an error; regenerate the dashboard to match. Routine lines (a request that went
well, a scan's end) are debug: the admin page's Log view has them, Loki does not. well, a scan's end) are debug: the admin page's Log view has them, Loki does not.
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a * `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
different task. `ipx status` checks the control socket and the database; to see the worker different task. `ipxd status` checks the control socket and the database; to see the worker
getting through its jobs, watch for `scan complete` in the log. getting through its jobs, watch for `scan complete` in the log.
* **The database goes through SeaORM, and the entities in `crates/ipx/src/entity.rs` are the schema.** * **The database goes through SeaORM, and the entities in `crates/ipx/src/entity.rs` are the schema.**
`Db::open` creates any missing table or index from them (`create_missing`), on every `ipx` `Db::open` creates any missing table or index from them (`create_missing`), on every `ipx`
command, the healthcheck's `ipx status` included, so it must never write when nothing is command, the healthcheck's `ipxd status` included, so it must never write when nothing is
missing: SeaORM's experimental schema sync dropped and remade an index on every open, the missing: SeaORM's experimental schema sync dropped and remade an index on every open, the
write lock that took made `ipx status` time out behind a busy daemon, and it was removed for write lock that took made `ipxd status` time out behind a busy daemon, and it was removed for
it. A new column on an existing table needs its own `ALTER`; nothing adds one for you. Look for it. A new column on an existing table needs its own `ALTER`; nothing adds one for you. Look for
the column first and alter only when it is missing, as `feeds.description` does. the column first and alter only when it is missing, as `feeds.description` does.
* **SQL written by hand in `db.rs` has to run on SQLite and Postgres both** (issue #18): `$1` * **SQL written by hand in `db.rs` has to run on SQLite and Postgres both** (issue #18): `$1`

354
Cargo.lock generated
View File

@@ -115,6 +115,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.9.2" version = "1.9.2"
@@ -507,6 +516,12 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "by_address"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
@@ -519,6 +534,15 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.4.5" version = "1.4.5"
@@ -650,6 +674,20 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "compact_str"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]] [[package]]
name = "compression-codecs" name = "compression-codecs"
version = "0.4.41" version = "0.4.41"
@@ -679,6 +717,15 @@ dependencies = [
"typewit", "typewit",
] ]
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@@ -768,6 +815,33 @@ version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crossterm"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags 2.13.1",
"crossterm_winapi",
"derive_more",
"document-features",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.6" version = "0.1.6"
@@ -994,6 +1068,7 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [ dependencies = [
"convert_case",
"proc-macro2", "proc-macro2",
"quote", "quote",
"rustc_version", "rustc_version",
@@ -1063,6 +1138,15 @@ dependencies = [
"syn 3.0.5", "syn 3.0.5",
] ]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]] [[package]]
name = "dontfrag" name = "dontfrag"
version = "1.0.1" version = "1.0.1"
@@ -1507,6 +1591,11 @@ name = "hashbrown"
version = "0.17.1" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]] [[package]]
name = "hashlink" name = "hashlink"
@@ -1843,6 +1932,28 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "indoc"
version = "2.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1"
dependencies = [
"rustversion",
]
[[package]]
name = "instability"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20"
dependencies = [
"darling 0.24.1",
"indoc",
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]] [[package]]
name = "intervaltree" name = "intervaltree"
version = "0.2.7" version = "0.2.7"
@@ -1945,6 +2056,19 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "ipx-tui"
version = "0.1.0-dev"
dependencies = [
"anyhow",
"chrono",
"clap",
"ratatui",
"reqwest",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.2" version = "1.70.2"
@@ -2120,6 +2244,17 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "kasuari"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209"
dependencies = [
"hashbrown 0.17.1",
"portable-atomic",
"thiserror 2.0.20",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -2483,12 +2618,33 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "line-clipping"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
dependencies = [
"bitflags 2.13.1",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.3" version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -2504,6 +2660,15 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lru"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
dependencies = [
"hashbrown 0.17.1",
]
[[package]] [[package]]
name = "lru-slab" name = "lru-slab"
version = "0.1.2" version = "0.1.2"
@@ -2639,6 +2804,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [ dependencies = [
"libc", "libc",
"log",
"wasi", "wasi",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -2806,6 +2972,15 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "num_threads"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -2921,6 +3096,39 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "palette"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64"
dependencies = [
"approx",
"libm",
"palette_derive",
"palette_math",
]
[[package]]
name = "palette_derive"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be"
dependencies = [
"by_address",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "palette_math"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12"
dependencies = [
"libm",
]
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@@ -3364,6 +3572,72 @@ dependencies = [
"rustversion", "rustversion",
] ]
[[package]]
name = "ratatui"
version = "0.30.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d"
dependencies = [
"instability",
"ratatui-core",
"ratatui-crossterm",
"ratatui-widgets",
"serde",
]
[[package]]
name = "ratatui-core"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
dependencies = [
"bitflags 2.13.1",
"compact_str",
"hashbrown 0.17.1",
"itertools 0.14.0",
"kasuari",
"lru",
"palette",
"serde",
"strum",
"thiserror 2.0.20",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
]
[[package]]
name = "ratatui-crossterm"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0"
dependencies = [
"cfg-if",
"crossterm",
"instability",
"ratatui-core",
]
[[package]]
name = "ratatui-widgets"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
dependencies = [
"bitflags 2.13.1",
"hashbrown 0.17.1",
"indoc",
"instability",
"itertools 0.14.0",
"line-clipping",
"ratatui-core",
"serde",
"strum",
"time",
"unicode-segmentation",
"unicode-width",
]
[[package]] [[package]]
name = "raw-cpuid" name = "raw-cpuid"
version = "11.6.0" version = "11.6.0"
@@ -3563,6 +3837,19 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustix"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
dependencies = [
"bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.46" version = "0.23.46"
@@ -4020,6 +4307,27 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.8" version = "1.4.8"
@@ -4298,6 +4606,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]] [[package]]
name = "string_cache" name = "string_cache"
version = "0.11.0" version = "0.11.0"
@@ -4344,6 +4658,21 @@ name = "strum"
version = "0.28.0" version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
@@ -4496,7 +4825,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [ dependencies = [
"deranged", "deranged",
"libc",
"num-conv", "num-conv",
"num_threads",
"powerfmt", "powerfmt",
"serde_core", "serde_core",
"time-core", "time-core",
@@ -4887,6 +5218,29 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-truncate"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
"itertools 0.14.0",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "unicode-width"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.2.6" version = "0.2.6"

View File

@@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["crates/ipx", "crates/feed", "crates/directory"] members = ["crates/ipx", "crates/feed", "crates/directory", "crates/tui"]
resolver = "3" resolver = "3"
# Each service has its own version, in its own Cargo.toml (ipx, ipx-directory) or package.json # Each service has its own version, in its own Cargo.toml (ipx, ipx-directory) or package.json

View File

@@ -13,17 +13,19 @@ COPY Cargo.toml Cargo.lock ./
COPY crates/ipx/Cargo.toml crates/ipx/ COPY crates/ipx/Cargo.toml crates/ipx/
COPY crates/feed/Cargo.toml crates/feed/ COPY crates/feed/Cargo.toml crates/feed/
COPY crates/directory/Cargo.toml crates/directory/ COPY crates/directory/Cargo.toml crates/directory/
RUN mkdir -p crates/ipx/src crates/feed/src crates/directory/src \ COPY crates/tui/Cargo.toml crates/tui/
RUN mkdir -p crates/ipx/src crates/feed/src crates/directory/src crates/tui/src \
&& echo 'fn main(){}' > crates/ipx/src/main.rs && touch crates/feed/src/lib.rs \ && echo 'fn main(){}' > crates/ipx/src/main.rs && touch crates/feed/src/lib.rs \
&& echo 'fn main(){}' > crates/directory/src/main.rs && touch crates/directory/src/lib.rs \ && echo 'fn main(){}' > crates/directory/src/main.rs && touch crates/directory/src/lib.rs \
&& echo 'fn main(){}' > crates/tui/src/main.rs \
&& cargo build --release --locked -p ipx \ && cargo build --release --locked -p ipx \
&& rm -rf crates/ipx/src crates/feed/src crates/directory/src && rm -rf crates/ipx/src crates/feed/src crates/directory/src crates/tui/src
COPY crates ./crates COPY crates ./crates
# ipx builds the directory crate in, whose public pages serve these (#215). # ipx builds the directory crate in, whose public pages serve these (#215).
COPY web/logo.svg web/logo-dark.svg web/favicon.png ./web/ COPY web/logo.svg web/logo-dark.svg web/favicon.png ./web/
# cargo skips a rebuild if mtimes look untouched; make sure it does not. # cargo skips a rebuild if mtimes look untouched; make sure it does not.
RUN touch crates/ipx/src/main.rs crates/feed/src/lib.rs && cargo build --release --locked -p ipx RUN touch crates/ipx/src/main.rs crates/ipx/src/lib.rs crates/feed/src/lib.rs && cargo build --release --locked -p ipx
FROM debian:trixie-slim FROM debian:trixie-slim
# ffmpeg is DeadAir's (#171): it finds the silences and copies the frames kept. Debian's adds # ffmpeg is DeadAir's (#171): it finds the silences and copies the frames kept. Debian's adds
@@ -31,7 +33,7 @@ FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates gosu ffmpeg \ ca-certificates gosu ffmpeg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=build /src/target/release/ipx /usr/local/bin/ipx COPY --from=build /src/target/release/ipxd /usr/local/bin/ipxd
COPY docker-entrypoint.sh /usr/local/bin/ COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh
@@ -46,4 +48,4 @@ VOLUME ["/config", "/data", "/downloads"]
EXPOSE 8099/tcp 6881/tcp 6881/udp EXPOSE 8099/tcp 6881/tcp 6881/udp
ENTRYPOINT ["docker-entrypoint.sh"] ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["ipx", "daemon"] CMD ["ipxd", "daemon"]

View File

@@ -44,7 +44,7 @@ From source:
```sh ```sh
cargo build --release cargo build --release
./target/release/ipx daemon ./target/release/ipxd daemon
``` ```
The first start creates **admin** with a random password, written to `admin-password` beside The first start creates **admin** with a random password, written to `admin-password` beside
@@ -52,7 +52,7 @@ config.toml (readable by ipx's user alone; the log says where, never what it is)
`IPX_ADMIN_PASSWORD` to choose it instead. Sign in at `/login`, then change it: `IPX_ADMIN_PASSWORD` to choose it instead. Sign in at `/login`, then change it:
```sh ```sh
echo -n 'a good password' | ipx user passwd admin echo -n 'a good password' | ipxd user passwd admin
``` ```
The UI is plain HTTP, so put TLS in front of it if it is reachable from outside your network. The UI is plain HTTP, so put TLS in front of it if it is reachable from outside your network.
@@ -62,7 +62,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
| | | | | |
|---|---| |---|---|
| [docs/configuration.md](docs/configuration.md) | Every config key, path and environment variable | | [docs/configuration.md](docs/configuration.md) | Every config key, path and environment variable |
| [docs/cli.md](docs/cli.md) | Every command, including `ipx user` | | [docs/cli.md](docs/cli.md) | Every command, including `ipxd user` |
| [docs/users.md](docs/users.md) | Accounts, and what several people share | | [docs/users.md](docs/users.md) | Accounts, and what several people share |
| [docs/sso.md](docs/sso.md) | Signing in through Cloudflare Zero Trust or Authentik | | [docs/sso.md](docs/sso.md) | Signing in through Cloudflare Zero Trust or Authentik |
| [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API | | [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API |

View File

@@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- The server's command is `ipxd`, not `ipx`: `ipxd daemon`, `ipxd status`, `ipxd user add` and the rest, in the container as `docker exec iPX ipxd ...`. `ipx` is now the reader in a terminal (#437).
- The log no longer has a line for every request that went well, over half its size: `/metrics` counts each request and its time by route and status as `ipx_http_request_duration_seconds`, a request that failed keeps its warning, and the admin page's log view still shows them all (#364). - The log no longer has a line for every request that went well, over half its size: `/metrics` counts each request and its time by route and status as `ipx_http_request_duration_seconds`, a request that failed keeps its warning, and the admin page's log view still shows them all (#364).
- The log no longer has a line for every scan that finishes, a fifth of its lines; the metrics count scans, and the page's log view still shows them (#363). - The log no longer has a line for every scan that finishes, a fifth of its lines; the metrics count scans, and the page's log view still shows them (#363).

View File

@@ -4,6 +4,12 @@ version = "1.5.2-dev"
edition.workspace = true edition.workspace = true
publish = false publish = false
# ipxd: `ipx` is the reader in a terminal (crates/tui), which more people type (#437). The
# code is the library, still `ipx`, so its log targets keep their names (src/lib.rs).
[[bin]]
name = "ipxd"
path = "src/main.rs"
[lints] [lints]
workspace = true workspace = true

View File

@@ -44,7 +44,7 @@ pub(crate) async fn add(
} }
/// Adds the feed at `url` to the catalogue unless it is there already, a feed an OPML lists /// Adds the feed at `url` to the catalogue unless it is there already, a feed an OPML lists
/// included, so one show is never added twice: its id, and whether it was there. For `ipx add` /// included, so one show is never added twice: its id, and whether it was there. For `ipxd add`
/// and the page's Add feed alike, which were the same steps written twice (#161); each finds the /// and the page's Add feed alike, which were the same steps written twice (#161); each finds the
/// feed first, to say its own way why an address is not one. /// feed first, to say its own way why an address is not one.
pub async fn add_to_catalogue( pub async fn add_to_catalogue(
@@ -151,7 +151,7 @@ pub(crate) async fn import(ctx: &Ctx, file: &Path) -> Result<()> {
.await? .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: ipxd user add <name> --admin"))?;
let doc = feed::read_opml(&text).map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?; let doc = feed::read_opml(&text).map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?;
let (added, had) = lists::subscribe_opml(ctx, lists::opml_feeds(&doc)?, admin.id).await?; let (added, had) = lists::subscribe_opml(ctx, lists::opml_feeds(&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);
@@ -168,7 +168,7 @@ pub(crate) async fn assemble_config(db: &db::Db, mut cfg: config::Config, path:
if config::Config::file_holds_stored(path) { if config::Config::file_holds_stored(path) {
tracing::warn!( tracing::warn!(
"config.toml still lists feeds or server settings; they are ignored, since the \ "config.toml still lists feeds or server settings; they are ignored, since the \
database holds them now. Change them in the web UI, or with ipx add and rm." database holds them now. Change them in the web UI, or with ipxd add and rm."
); );
} }
stored.apply(&mut cfg); stored.apply(&mut cfg);
@@ -213,13 +213,13 @@ pub(crate) async fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
pub(crate) async fn list(ctx: &Ctx) -> Result<()> { pub(crate) async fn list(ctx: &Ctx) -> Result<()> {
let cfg = ctx.cfg(); let cfg = ctx.cfg();
if cfg.feeds.is_empty() { if cfg.feeds.is_empty() {
println!("No feeds configured. Add one with `ipx add <url>`, or in the web UI."); println!("No feeds configured. Add one with `ipxd add <url>`, or in the web UI.");
return Ok(()); return Ok(());
} }
for (id, feed) in &cfg.feeds { for (id, feed) in &cfg.feeds {
let s = ctx.db.feed_summary(id).await?; let s = ctx.db.feed_summary(id).await?;
// The id on a line of its own, labelled: first on the title's line, antirez.com's id // The id on a line of its own, labelled: first on the title's line, antirez.com's id
// "feed" read as a heading and 'ipx fetch antirez' was tried instead (issue #82). // "feed" read as a heading and 'ipxd fetch antirez' was tried instead (issue #82).
println!("{}", s.title.as_deref().filter(|t| !t.is_empty()).unwrap_or(id)); println!("{}", s.title.as_deref().filter(|t| !t.is_empty()).unwrap_or(id));
println!(" id {id}"); println!(" id {id}");
println!(" url {}", feed.url); println!(" url {}", feed.url);

View File

@@ -10,7 +10,7 @@ use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "ipx", version, about = "A headless podcatcher")] #[command(name = "ipxd", version, about = "A headless podcatcher")]
pub(crate) struct Cli { pub(crate) struct Cli {
/// Config file (default: $XDG_CONFIG_HOME/ipx/config.toml) /// Config file (default: $XDG_CONFIG_HOME/ipx/config.toml)
#[arg(long, global = true)] #[arg(long, global = true)]
@@ -81,7 +81,7 @@ pub(crate) enum Command {
#[derive(Subcommand)] #[derive(Subcommand)]
pub(crate) enum UserCmd { pub(crate) enum UserCmd {
/// Create an account. The password is read from stdin: `echo -n hunter2 | ipx user add ray` /// Create an account. The password is read from stdin: `echo -n hunter2 | ipxd user add ray`
Add { Add {
name: String, name: String,
/// May manage other accounts, and is who the shared web token signs in as /// May manage other accounts, and is who the shared web token signs in as
@@ -110,7 +110,7 @@ pub(crate) async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd, mut stdin: impl std::
stdin.read_to_string(&mut buf)?; stdin.read_to_string(&mut buf)?;
let pw = buf.trim_end_matches(['\n', '\r']).to_string(); let pw = buf.trim_end_matches(['\n', '\r']).to_string();
if pw.is_empty() { if pw.is_empty() {
anyhow::bail!("no password on stdin: try `echo -n secret | ipx user ...`"); anyhow::bail!("no password on stdin: try `echo -n secret | ipxd user ...`");
} }
Ok(pw) Ok(pw)
}; };
@@ -138,7 +138,7 @@ pub(crate) async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd, mut stdin: impl std::
UserCmd::List => { UserCmd::List => {
let users = ctx.db.users().await?; let users = ctx.db.users().await?;
if users.is_empty() { if users.is_empty() {
println!("no accounts yet: ipx user add <name>"); println!("no accounts yet: ipxd user add <name>");
} }
for u in users { for u in users {
let added = u let added = u

View File

@@ -219,7 +219,7 @@ pub struct Feed {
/// Preferred over `password`: name of an env var holding the password. /// Preferred over `password`: name of an env var holding the password.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub password_env: Option<String>, pub password_env: Option<String>,
/// Put in the Directory by an admin (`ipx add --list`): listed with no subscribers, and kept /// Put in the Directory by an admin (`ipxd add --list`): listed with no subscribers, and kept
/// in the catalogue when the last one leaves, where anyone else's feed is dropped. /// in the catalogue when the last one leaves, where anyone else's feed is dropped.
#[serde(default, skip_serializing_if = "std::ops::Not::not")] #[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub listed: bool, pub listed: bool,

View File

@@ -48,7 +48,7 @@ pub struct Ctx {
/// Held from taking a copy of the config to storing it (`edit_cfg`). Each change stores the /// Held from taking a copy of the config to storing it (`edit_cfg`). Each change stores the
/// whole catalogue from its copy, and two at once stored over each other: a feed added while /// whole catalogue from its copy, and two at once stored over each other: a feed added while
/// another add spent a minute fetching was gone once that one stored (#153). /// another add spent a minute fetching was gone once that one stored (#153).
// ponytail: this process only. `ipx add`, `rm` and `import` write from a process of their // ponytail: this process only. `ipxd add`, `rm` and `import` write from a process of their
// own, and a change in flight here can still store over theirs; take the copy from the // own, and a change in flight here can still store over theirs; take the copy from the
// database rather than memory if that ever happens. // database rather than memory if that ever happens.
pub cfg_write: tokio::sync::Mutex<()>, pub cfg_write: tokio::sync::Mutex<()>,

View File

@@ -24,7 +24,7 @@ pub(crate) async fn reload(ctx: &Ctx) -> Result<()> {
Ok(()) Ok(())
} }
/// The counts `ipx status` prints, and /api/status serves. A running daemon's socket answers with /// The counts `ipxd status` prints, and /api/status serves. A running daemon's socket answers with
/// this directly rather than through the job queue. /// this directly rather than through the job queue.
pub(crate) async fn status(ctx: &Ctx) -> Event { pub(crate) async fn status(ctx: &Ctx) -> Event {
match tracing::Instrument::instrument(ctx.db.counts(), db::step("counts")).await { match tracing::Instrument::instrument(ctx.db.counts(), db::step("counts")).await {
@@ -66,7 +66,7 @@ pub(crate) async fn daemon(
let (password, told) = auth::first_password(dir, std::env::var("IPX_ADMIN_PASSWORD").ok())?; let (password, told) = auth::first_password(dir, std::env::var("IPX_ADMIN_PASSWORD").ok())?;
ctx.db.create_user("admin", Some(&crate::auth::hash_password(&password)?), /* admin */ true).await?; ctx.db.create_user("admin", Some(&crate::auth::hash_password(&password)?), /* admin */ true).await?;
tracing::warn!( tracing::warn!(
"no accounts yet: created 'admin' {told}. Change it with `echo -n <password> | ipx user passwd admin`" "no accounts yet: created 'admin' {told}. Change it with `echo -n <password> | ipxd user passwd admin`"
); );
} }
@@ -342,7 +342,7 @@ pub(crate) async fn shutdown() {
/// How long the daemon may sleep before a feed is due: until the earliest one, at least 30 s and /// How long the daemon may sleep before a feed is due: until the earliest one, at least 30 s and
/// at most 10 minutes. It ticked every minute and ran a scan pass each time, 80% of them finding /// at most 10 minutes. It ticked every minute and ran a scan pass each time, 80% of them finding
/// nothing due (#114). The floor keeps a feed that never gets a check time from spinning it; the /// nothing due (#114). The floor keeps a feed that never gets a check time from spinning it; the
/// ceiling picks up within ten minutes what no command announces, such as `ipx add` or a /// ceiling picks up within ten minutes what no command announces, such as `ipxd add` or a
/// shorter schedule. A command, a refresh or a feed added on the page, wakes it at once anyway. /// shorter schedule. A command, a refresh or a feed added on the page, wakes it at once anyway.
pub(crate) async fn until_next_scan(ctx: &Ctx) -> std::time::Duration { pub(crate) async fn until_next_scan(ctx: &Ctx) -> std::time::Duration {
let next = async { let next = async {
@@ -398,7 +398,7 @@ mod tests {
let held = with("kept-in-memory"); let held = with("kept-in-memory");
let ctx = test_ctx(held.clone()).await; let ctx = test_ctx(held.clone()).await;
ctx.db.store_config(&held).await.unwrap(); ctx.db.store_config(&held).await.unwrap();
// `ipx add` and `ipx rm`, from a process of their own (#120). // `ipxd add` and `ipxd rm`, from a process of their own (#120).
ctx.db.store_config(&with("added-by-the-cli")).await.unwrap(); ctx.db.store_config(&with("added-by-the-cli")).await.unwrap();
reload(&ctx).await.unwrap(); reload(&ctx).await.unwrap();
let urls: Vec<String> = ctx.cfg().feeds.values().map(|f| f.url.clone()).collect(); let urls: Vec<String> = ctx.cfg().feeds.values().map(|f| f.url.clone()).collect();

View File

@@ -43,7 +43,7 @@ impl Drop for Db {
/// ///
/// Not SeaORM's schema sync, which is experimental and, despite its docs, drops a unique index /// Not SeaORM's schema sync, which is experimental and, despite its docs, drops a unique index
/// the entities do not describe: it dropped users_name_lower on every open, so every `ipx` /// the entities do not describe: it dropped users_name_lower on every open, so every `ipx`
/// command took a write lock, and the healthcheck's `ipx status` timed out behind a busy daemon. /// command took a write lock, and the healthcheck's `ipxd status` timed out behind a busy daemon.
/// On Postgres it would have failed outright, dropping that index as a constraint. /// On Postgres it would have failed outright, dropping that index as a constraint.
/// ///
/// ponytail: tables only, no columns. A column added to an existing table needs its own ALTER /// ponytail: tables only, no columns. A column added to an existing table needs its own ALTER
@@ -98,7 +98,7 @@ async fn create_missing(orm: &DatabaseConnection) -> Result<()> {
} }
// A column added to a table that already exists, which CREATE TABLE IF NOT EXISTS leaves // A column added to a table that already exists, which CREATE TABLE IF NOT EXISTS leaves
// as it was. Looked for first, and added only when it is missing: this runs on every open, // as it was. Looked for first, and added only when it is missing: this runs on every open,
// the healthcheck's `ipx status` included, and an ALTER takes a lock that waits behind a // the healthcheck's `ipxd status` included, and an ALTER takes a lock that waits behind a
// busy daemon, which is what made the healthcheck time out once before. // busy daemon, which is what made the healthcheck time out once before.
if orm.execute_unprepared("SELECT description FROM feeds LIMIT 0").await.is_err() { if orm.execute_unprepared("SELECT description FROM feeds LIMIT 0").await.is_err() {
// A feed is read whole only when it has changed since the last read, so each one's // A feed is read whole only when it has changed since the last read, so each one's
@@ -272,7 +272,7 @@ async fn connect(location: &str, keep_open: bool) -> Result<DatabaseConnection>
// The daemon keeps every connection open (#241): a Postgres connection takes 80-330 ms to // The daemon keeps every connection open (#241): a Postgres connection takes 80-330 ms to
// open, its password exchange most of it, and a page load asked for more than were idle, so // open, its password exchange most of it, and a page load asked for more than were idle, so
// the feed list and item list waited on new ones, 300-900 ms where their queries took 5-30. // the feed list and item list waited on new ones, 300-900 ms where their queries took 5-30.
// Not a command such as `ipx status`, nor a test, which would hold twenty for one question. // Not a command such as `ipxd status`, nor a test, which would hold twenty for one question.
if keep_open && is_postgres(location) { if keep_open && is_postgres(location) {
opts.min_connections(20); opts.min_connections(20);
} }
@@ -408,7 +408,7 @@ pub struct Managed {
pub group_id: String, pub group_id: String,
} }
/// What `ipx list` shows next to each configured feed. /// What `ipxd list` shows next to each configured feed.
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct FeedSummary { pub struct FeedSummary {
pub title: Option<String>, pub title: Option<String>,
@@ -438,7 +438,7 @@ impl Db {
} }
let orm = connect(location, keep_open).await?; let orm = connect(location, keep_open).await?;
if !is_postgres(location) { if !is_postgres(location) {
// WAL, so the healthcheck's `ipx status` reads while the daemon writes. It is a // WAL, so the healthcheck's `ipxd status` reads while the daemon writes. It is a
// setting of the file, kept once made, and making it takes a lock that cannot wait out // setting of the file, kept once made, and making it takes a lock that cannot wait out
// a busy daemon, so it is made only when the file is not already in WAL. // a busy daemon, so it is made only when the file is not already in WAL.
let mode = let mode =

View File

@@ -212,7 +212,7 @@ pub enum Command {
enclosure: i64, enclosure: i64,
}, },
Status, Status,
/// Read the catalogue and server settings again from the database, which `ipx add`, `rm` /// Read the catalogue and server settings again from the database, which `ipxd add`, `rm`
/// and `import` change in a process of their own. A daemon keeps them in memory, and with /// and `import` change in a process of their own. A daemon keeps them in memory, and with
/// its old copy wrote a removed feed back, and dropped an added one, at its next change of /// its old copy wrote a removed feed back, and dropped an added one, at its next change of
/// its own (#120). Answered, as status is, with the counts after it. /// its own (#120). Answered, as status is, with the counts after it.
@@ -329,7 +329,7 @@ fn log_event(e: &Event, wire: bool) {
) )
}; };
} }
// `ipx status`'s answer: a reply on the socket rather than work done, so it // `ipxd status`'s answer: a reply on the socket rather than work done, so it
// stays with the rest of the conversation, and the Scans tab stays about scans. // stays with the rest of the conversation, and the Scans tab stays about scans.
if matches!(e, Event::Status { .. }) { if matches!(e, Event::Status { .. }) {
return line!(info, "ipx::io", format!("<- {json}")); return line!(info, "ipx::io", format!("<- {json}"));
@@ -356,7 +356,7 @@ pub async fn daemon_is_live(path: &Path) -> bool {
/// time, and a healthcheck left waiting behind a scan or a long download timed out and called a /// time, and a healthcheck left waiting behind a scan or a long download timed out and called a
/// busy daemon dead; a reload left waiting would let the web UI write the old catalogue back in /// busy daemon dead; a reload left waiting would let the web UI write the old catalogue back in
/// 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 `ipxd 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 = Arc<dyn Fn(Command) -> Pin<Box<dyn Future<Output = Event> + Send>> + Send + Sync>; pub type StatusFn = Arc<dyn Fn(Command) -> Pin<Box<dyn Future<Output = Event> + Send>> + Send + Sync>;

130
crates/ipx/src/lib.rs Normal file
View File

@@ -0,0 +1,130 @@
//! ipx, the daemon and its command line. The code is in the modules below; `main` starts it.
//! A library under the binary `ipxd` (src/main.rs) so that its modules, and so its log targets
//! (`ipx::scan`, `ipx::http`), the filters (`ipx=info`) and the dashboard's queries on them, kept
//! their names when the command was renamed (#437).
mod access;
mod art;
mod auth;
mod catalogue;
mod cli;
mod config;
mod ctx;
mod daemon;
mod db;
mod deadair;
mod directory;
mod download;
mod entity;
// The crate root's import, so `crate::feed` reaches it from every module as before.
use ipx_feed as feed;
mod ipc;
mod lists;
mod logbuf;
mod logging;
mod metrics;
mod push;
mod retention;
mod scan;
mod signals;
#[cfg(test)]
mod test_util;
mod torrent;
mod web;
use crate::cli::{Cli, Command, UserCmd};
use crate::ctx::Ctx;
use crate::ipc::Command as Cmd;
use anyhow::Result;
use clap::Parser;
use std::sync::Arc;
use tokio::sync::broadcast;
use tracing::Instrument;
#[tokio::main]
pub async fn main() -> Result<()> {
let cli = Cli::parse();
// The daemon only: a trace for every `ipxd status` a script runs would bury the ones worth
// looking at.
let otel = match cli.command {
Command::Daemon { .. } => ipx_directory::otel::provider("ipx")?,
_ => None,
};
logging::init(otel.as_ref());
// For the spans the page reports of itself (#376), which ipx builds with the page's own ids.
if let Some(p) = &otel {
opentelemetry::global::set_tracer_provider(p.clone());
}
let config_path = cli.config.clone().unwrap_or_else(config::config_path);
let cfg = config::Config::load(&config_path)?;
// The daemon holds its connections open; a one-off command opens what it uses.
let keep_open = matches!(cli.command, Command::Daemon { .. });
// The daemon's start-up is a trace (#374), from opening the database to serving; daemon()
// carries it on.
let start = if keep_open { tracing::info_span!("start") } else { tracing::Span::none() };
let db = async { db::Db::open(&db::location(), keep_open).instrument(db::step("open_database")).await }
.instrument(start.clone())
.await?;
// A daemon owns the state; don't have two processes downloading the same thing.
let wire_cmd = match &cli.command {
Command::Fetch { feed, force } => Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] }),
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
Command::Status => Some(Cmd::Status),
Command::List
| Command::Daemon { .. }
| Command::User { .. }
| Command::Add { .. }
| Command::Rm { .. }
| Command::Import { .. }
| Command::Export { .. } => None,
};
if let Some(cmd) = &wire_cmd
&& !cli.local
&& ipc::daemon_is_live(&cfg.general.socket).await
{
return ipc::proxy(&cfg.general.socket, cmd).await;
}
let socket = cfg.general.socket.clone();
let cfg =
async { catalogue::assemble_config(&db, cfg, &config_path).instrument(db::step("assemble_config")).await }
.instrument(start.clone())
.await?;
let is_daemon = matches!(cli.command, Command::Daemon { .. });
// These write the catalogue from a process of their own, which a running daemon, holding its
// own copy, would write over at its next change (#120).
let changes_catalogue = matches!(
cli.command,
Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } | Command::User { cmd: UserCmd::Rm { .. } }
);
let (events, _) = broadcast::channel(1024);
let ctx = Arc::new(Ctx::new(cfg, db, config_path.clone(), is_daemon, &events)?);
let result = match cli.command {
Command::List => catalogue::list(&ctx).await,
Command::Daemon { web } => daemon::daemon(ctx, config_path, web, events, start).await,
Command::Add { url, folder, keywords, category, list } => {
catalogue::add(&ctx, &url, folder, keywords, category, list).await
}
Command::Rm { feed } => catalogue::rm(&ctx, &feed).await,
Command::User { cmd } => cli::user_cmd(&ctx, cmd, std::io::stdin()).await,
Command::Import { file } => catalogue::import(&ctx, &file).await,
Command::Export { file } => catalogue::export(&ctx, &file).await,
_ => cli::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
&& let Err(e) = ipc::reload(&socket).await
{
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.
if let Some(p) = otel {
let _ = p.shutdown();
}
result
}

View File

@@ -1,127 +1,5 @@
//! ipx, the daemon and its command line. The code is in the modules below; this only starts it. //! `ipxd`, the daemon and its command line; everything is in the library (lib.rs).
mod access; fn main() -> anyhow::Result<()> {
mod art; ipx::main()
mod auth;
mod catalogue;
mod cli;
mod config;
mod ctx;
mod daemon;
mod db;
mod deadair;
mod directory;
mod download;
mod entity;
// The crate root's import, so `crate::feed` reaches it from every module as before.
use ipx_feed as feed;
mod ipc;
mod lists;
mod logbuf;
mod logging;
mod metrics;
mod push;
mod retention;
mod scan;
mod signals;
#[cfg(test)]
mod test_util;
mod torrent;
mod web;
use crate::cli::{Cli, Command, UserCmd};
use crate::ctx::Ctx;
use crate::ipc::Command as Cmd;
use anyhow::Result;
use clap::Parser;
use std::sync::Arc;
use tokio::sync::broadcast;
use tracing::Instrument;
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// The daemon only: a trace for every `ipx status` a script runs would bury the ones worth
// looking at.
let otel = match cli.command {
Command::Daemon { .. } => ipx_directory::otel::provider("ipx")?,
_ => None,
};
logging::init(otel.as_ref());
// For the spans the page reports of itself (#376), which ipx builds with the page's own ids.
if let Some(p) = &otel {
opentelemetry::global::set_tracer_provider(p.clone());
}
let config_path = cli.config.clone().unwrap_or_else(config::config_path);
let cfg = config::Config::load(&config_path)?;
// The daemon holds its connections open; a one-off command opens what it uses.
let keep_open = matches!(cli.command, Command::Daemon { .. });
// The daemon's start-up is a trace (#374), from opening the database to serving; daemon()
// carries it on.
let start = if keep_open { tracing::info_span!("start") } else { tracing::Span::none() };
let db = async { db::Db::open(&db::location(), keep_open).instrument(db::step("open_database")).await }
.instrument(start.clone())
.await?;
// A daemon owns the state; don't have two processes downloading the same thing.
let wire_cmd = match &cli.command {
Command::Fetch { feed, force } => Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] }),
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
Command::Status => Some(Cmd::Status),
Command::List
| Command::Daemon { .. }
| Command::User { .. }
| Command::Add { .. }
| Command::Rm { .. }
| Command::Import { .. }
| Command::Export { .. } => None,
};
if let Some(cmd) = &wire_cmd
&& !cli.local
&& ipc::daemon_is_live(&cfg.general.socket).await
{
return ipc::proxy(&cfg.general.socket, cmd).await;
}
let socket = cfg.general.socket.clone();
let cfg =
async { catalogue::assemble_config(&db, cfg, &config_path).instrument(db::step("assemble_config")).await }
.instrument(start.clone())
.await?;
let is_daemon = matches!(cli.command, Command::Daemon { .. });
// These write the catalogue from a process of their own, which a running daemon, holding its
// own copy, would write over at its next change (#120).
let changes_catalogue = matches!(
cli.command,
Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } | Command::User { cmd: UserCmd::Rm { .. } }
);
let (events, _) = broadcast::channel(1024);
let ctx = Arc::new(Ctx::new(cfg, db, config_path.clone(), is_daemon, &events)?);
let result = match cli.command {
Command::List => catalogue::list(&ctx).await,
Command::Daemon { web } => daemon::daemon(ctx, config_path, web, events, start).await,
Command::Add { url, folder, keywords, category, list } => {
catalogue::add(&ctx, &url, folder, keywords, category, list).await
}
Command::Rm { feed } => catalogue::rm(&ctx, &feed).await,
Command::User { cmd } => cli::user_cmd(&ctx, cmd, std::io::stdin()).await,
Command::Import { file } => catalogue::import(&ctx, &file).await,
Command::Export { file } => catalogue::export(&ctx, &file).await,
_ => cli::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
&& let Err(e) = ipc::reload(&socket).await
{
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.
if let Some(p) = otel {
let _ = p.shutdown();
}
result
} }

View File

@@ -8,7 +8,7 @@ use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
pub(crate) fn feed() -> config::Feed { pub(crate) fn feed() -> config::Feed {
// Whatever `ipx add` would write, which is the shape every code path sees. // Whatever `ipxd add` would write, which is the shape every code path sees.
let mut cfg = config::Config::default(); let mut cfg = config::Config::default();
add_one_cfg(&mut cfg, "http://x/f.xml", None, vec![]) add_one_cfg(&mut cfg, "http://x/f.xml", None, vec![])

View File

@@ -671,7 +671,7 @@ fn require_admin(user: &crate::db::User) -> Result<(), ApiError> {
} }
/// Demoting or removing this account would leave nobody able to manage anyone, and the only /// Demoting or removing this account would leave nobody able to manage anyone, and the only
/// way back would be `ipx user` on the box. /// way back would be `ipxd user` on the box.
fn last_admin(users: &[crate::db::User], id: i64) -> bool { fn last_admin(users: &[crate::db::User], id: i64) -> bool {
let admins: Vec<i64> = users.iter().filter(|u| u.is_admin).map(|u| u.id).collect(); let admins: Vec<i64> = users.iter().filter(|u| u.is_admin).map(|u| u.id).collect();
admins == [id] admins == [id]
@@ -798,7 +798,7 @@ async fn add_user(
if state.ctx.db.user_by_name(&name).await?.is_some() { if state.ctx.db.user_by_name(&name).await?.is_some() {
return Err(ApiError::bad_request(format!("{name} already exists"))); return Err(ApiError::bad_request(format!("{name} already exists")));
} }
// No password is someone the proxy signs in, as with `ipx user add --no-password`. // No password is someone the proxy signs in, as with `ipxd user add --no-password`.
let hash = if body.password.is_empty() { let hash = if body.password.is_empty() {
None None
} else { } else {
@@ -2737,7 +2737,7 @@ async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Respon
crate::metrics::STATS.art_hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed); crate::metrics::STATS.art_hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
return art_response(kind, body); return art_response(kind, body);
} }
// Only what a feed here or the directory names: anything else would make ipx fetch any // Only what a feed here or the directory names: anything else would make ipxd fetch any
// address it is given. The Directory shows podcasts nobody here subscribes to (#273). // address it is given. The Directory shows podcasts nobody here subscribes to (#273).
if !crate::directory::listed_art(&q.u) if !crate::directory::listed_art(&q.u)
&& !crate::art::named_in_text(&q.u) && !crate::art::named_in_text(&q.u)
@@ -3008,7 +3008,7 @@ async fn import_opml(
Ok(Json(serde_json::json!({ "added": added, "already": already }))) Ok(Json(serde_json::json!({ "added": added, "already": already })))
} }
/// The numbers `ipx status` prints, and the version, for a dashboard such as Homepage's /// The numbers `ipxd status` prints, and the version, for a dashboard such as Homepage's
/// customapi widget. Behind sign-in like the rest of /api; Homepage sends the shared token. /// customapi widget. Behind sign-in like the rest of /api; Homepage sends the shared token.
async fn status(State(state): State<WebState>) -> Response { async fn status(State(state): State<WebState>) -> Response {
match crate::daemon::status(&state.ctx).await { match crate::daemon::status(&state.ctx).await {

View File

@@ -93,7 +93,7 @@ url = "http://127.0.0.1:{feed_port}/feed.xml"
} }
fn ipx(&self, args: &[&str]) -> Command { fn ipx(&self, args: &[&str]) -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_ipx")); let mut c = Command::new(env!("CARGO_BIN_EXE_ipxd"));
c.args(args) c.args(args)
.env("IPX_CONFIG", self.root.join("config.toml")) .env("IPX_CONFIG", self.root.join("config.toml"))
.env("IPX_DATA_DIR", self.root.join("data")) .env("IPX_DATA_DIR", self.root.join("data"))

14
crates/tui/CHANGELOG.md Normal file
View File

@@ -0,0 +1,14 @@
# Changelog: ipx-tui
The reader in a terminal (README.md). ipx's changelog is [crates/ipx/CHANGELOG.md](../ipx/CHANGELOG.md).
Its version is in `crates/tui/Cargo.toml`.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- `ipx`, iPX's reader in a terminal: your feeds, a feed's items and an item's text, read and flagged as on the page, signed in with an API token (#437).

21
crates/tui/Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "ipx-tui"
version = "0.1.0-dev"
edition.workspace = true
publish = false
[lints]
workspace = true
[[bin]]
name = "ipx"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap = { workspace = true, features = ["env"] }
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] }
reqwest = { workspace = true, features = ["blocking"] }
serde.workspace = true
serde_json.workspace = true

27
crates/tui/README.md Normal file
View File

@@ -0,0 +1,27 @@
# ipx-tui
The `ipx` command. The server, which had that name, is `ipxd`.
iPX's reader in a terminal: your feeds on the left, a feed's items above, the item's text below.
It talks to iPX's API as the page does, as you, with an API token made in the page's Settings.
```sh
cargo build --release -p ipx-tui
IPX_URL=http://192.168.1.130:8099 IPX_API_TOKEN=... target/release/ipx
```
`--url` and `--token` do the same as the two variables.
| key | does |
|---|---|
| `j` `k`, arrows | move, or scroll the item's text |
| `Enter`, `l`, `Tab` | open the feed or the item; opening an item marks it read |
| `h`, `Esc` | back a pane; `Esc` on the feeds quits |
| `Space`, `PgUp` `PgDn` | page through the item's text |
| `m` | mark the item read or unread |
| `f` | flag or unflag it |
| `u` | unread items only, or every item (unread only to start) |
| `r` | reload |
| `q` | quit |
It shows a feed's newest 200 items.

451
crates/tui/src/main.rs Normal file
View File

@@ -0,0 +1,451 @@
//! A terminal reader for ipx (#437): your feeds, a feed's items and an item's text, through the
//! same API the page uses, as the person whose API token it is given.
use anyhow::{Context, Result, bail};
use clap::Parser;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Modifier, Style, Stylize};
use ratatui::text::Line;
use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::{DefaultTerminal, Frame};
use reqwest::Url;
use serde::Deserialize;
use std::time::Duration;
#[derive(Parser)]
#[command(name = "ipx", version, about = "Read iPX in a terminal")]
struct Args {
/// Where iPX is, as a browser reaches it.
#[arg(long, env = "IPX_URL", default_value = "http://localhost:8099")]
url: Url,
/// An API token, made in the page's Settings.
#[arg(long, env = "IPX_API_TOKEN", hide_env_values = true)]
token: String,
}
/// The part of web.rs's FeedRow this needs.
#[derive(Deserialize)]
struct Feed {
id: String,
url: String,
title: Option<String>,
unread: i64,
/// The OPML it came from.
group: Option<String>,
hide_from_all: bool,
}
/// The part of db.rs's EntryRow this needs.
#[derive(Deserialize)]
struct Entry {
guid: String,
feed_id: String,
title: Option<String>,
link: Option<String>,
published: Option<i64>,
description: Option<String>,
read: bool,
flagged: bool,
}
#[derive(Deserialize)]
struct EntryPage {
total: i64,
entries: Vec<Entry>,
}
struct Api {
http: reqwest::blocking::Client,
base: Url,
token: String,
}
impl Api {
/// The API's address for these path segments, each escaped: a guid is often a URL itself.
fn url(&self, segments: &[&str]) -> Url {
let mut url = self.base.clone();
url.path_segments_mut().expect("an http URL has a path").pop_if_empty().extend(segments);
url
}
fn send(&self, req: reqwest::blocking::RequestBuilder) -> Result<reqwest::blocking::Response> {
let res = req.bearer_auth(&self.token).send()?;
if !res.status().is_success() {
bail!("{} {}", res.status(), res.text().unwrap_or_default());
}
Ok(res)
}
fn feeds(&self) -> Result<Vec<Feed>> {
Ok(self.send(self.http.get(self.url(&["api", "feeds"])))?.json()?)
}
/// The newest items of one feed, or of all of them when `feed` is None.
// shortcut: the newest 200 only, no paging; page through `offset` if someone wants older.
fn entries(&self, feed: Option<&str>, unread_only: bool) -> Result<EntryPage> {
let mut url = match feed {
Some(id) => self.url(&["api", "feeds", id, "entries"]),
None => self.url(&["api", "entries"]),
};
let filter = if unread_only { "unread" } else { "all" };
url.query_pairs_mut().append_pair("limit", "200").append_pair("filter", filter);
Ok(self.send(self.http.get(url))?.json()?)
}
/// Sets an item's read or flagged, and answers with its feed's row, for the new unread count.
fn set_flag(&self, e: &Entry, flag: &str, on: bool) -> Result<Option<Feed>> {
let url = self.url(&["api", "entries", &e.feed_id, &e.guid, "flags"]);
Ok(self.send(self.http.post(url).json(&serde_json::json!({ flag: on })))?.json()?)
}
}
#[derive(PartialEq, Clone, Copy)]
enum Pane {
Feeds,
Items,
Text,
}
struct App {
api: Api,
/// Your feeds, after All Subscriptions, which is index 0 of `feed_list`.
feeds: Vec<Feed>,
feed_list: ListState,
entries: Vec<Entry>,
total: i64,
entry_list: ListState,
pane: Pane,
scroll: u16,
unread_only: bool,
status: String,
}
impl App {
fn feed_id(&self) -> Option<&str> {
let i = self.feed_list.selected()?;
self.feeds.get(i.checked_sub(1)?).map(|f| f.id.as_str())
}
fn entry(&self) -> Option<&Entry> {
self.entries.get(self.entry_list.selected()?)
}
/// Runs an API call, putting what went wrong in the status line instead of ending the program.
fn attempt<T>(&mut self, what: Result<T>) -> Option<T> {
what.map_err(|e| self.status = format!("{e:#}")).ok()
}
fn load_feeds(&mut self) {
if let Some(feeds) = self.attempt(self.api.feeds()) {
self.feeds = feeds;
self.status.clear();
}
}
fn load_entries(&mut self) {
let page = self.api.entries(self.feed_id(), self.unread_only);
if let Some(page) = self.attempt(page) {
self.entries = page.entries;
self.total = page.total;
self.entry_list.select(if self.entries.is_empty() { None } else { Some(0) });
self.scroll = 0;
self.status.clear();
}
}
/// Sets the selected item's read or flagged, as the page does.
fn flag(&mut self, flag: &str, on: impl Fn(&Entry) -> bool) {
let Some(e) = self.entry() else { return };
let on = on(e);
let result = self.api.set_flag(e, flag, on);
let Some(row) = self.attempt(result) else { return };
if let Some(i) = self.entry_list.selected() {
let e = &mut self.entries[i];
if flag == "read" { e.read = on } else { e.flagged = on }
}
if let Some(row) = row
&& let Some(f) = self.feeds.iter_mut().find(|f| f.id == row.id)
{
f.unread = row.unread;
}
}
/// Opens the selected item, which marks it read, as opening one on the page does.
fn open(&mut self) {
self.scroll = 0;
self.pane = Pane::Text;
if self.entry().is_some_and(|e| !e.read) {
self.flag("read", |_| true);
}
}
fn step(&mut self, down: bool) {
let list = match self.pane {
Pane::Feeds => &mut self.feed_list,
Pane::Items => &mut self.entry_list,
Pane::Text => {
self.scroll = if down { self.scroll.saturating_add(1) } else { self.scroll.saturating_sub(1) };
return;
}
};
if down {
list.select_next()
} else {
list.select_previous()
}
if self.pane == Pane::Items {
self.scroll = 0;
}
}
/// Handles a key, answering false when it is time to quit.
fn key(&mut self, code: KeyCode) -> bool {
match code {
KeyCode::Char('q') => return false,
KeyCode::Esc if self.pane == Pane::Feeds => return false,
KeyCode::Char('j') | KeyCode::Down => self.step(true),
KeyCode::Char('k') | KeyCode::Up => self.step(false),
KeyCode::PageDown | KeyCode::Char(' ') if self.pane == Pane::Text => {
self.scroll = self.scroll.saturating_add(10)
}
KeyCode::PageUp if self.pane == Pane::Text => self.scroll = self.scroll.saturating_sub(10),
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => match self.pane {
Pane::Feeds => {
self.load_entries();
self.pane = Pane::Items;
}
Pane::Items => self.open(),
Pane::Text => {}
},
KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left | KeyCode::BackTab => {
self.pane = match self.pane {
Pane::Text => Pane::Items,
_ => Pane::Feeds,
}
}
KeyCode::Char('m') => self.flag("read", |e| !e.read),
KeyCode::Char('f') => self.flag("flagged", |e| !e.flagged),
KeyCode::Char('u') => {
self.unread_only = !self.unread_only;
self.load_entries();
}
KeyCode::Char('r') => {
self.load_feeds();
self.load_entries();
}
_ => {}
}
true
}
fn draw(&mut self, frame: &mut Frame) {
let [main, foot] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area());
let [left, right] = Layout::horizontal([Constraint::Percentage(28), Constraint::Fill(1)]).areas(main);
let [top, bottom] = Layout::vertical([Constraint::Percentage(40), Constraint::Fill(1)]).areas(right);
let block = |title: String, pane: Pane| {
let b = Block::bordered().title(title);
if self.pane == pane { b.border_style(Style::new().bold()) } else { b.dim() }
};
let picked = Style::new().add_modifier(Modifier::REVERSED);
// What the page counts (feeds.ts inAll): not a feed hidden from All Subscriptions, or
// one from an OPML that is (#288).
let hidden = |id: &str| self.feeds.iter().any(|g| g.id == id && g.hide_from_all);
let all: i64 = self
.feeds
.iter()
.filter(|f| !f.hide_from_all && !f.group.as_deref().is_some_and(hidden))
.map(|f| f.unread)
.sum();
let feeds = std::iter::once(format!("All Subscriptions ({all})")).chain(self.feeds.iter().map(|f| {
let name = f.title.as_deref().unwrap_or(&f.url);
if f.unread > 0 { format!("{name} ({})", f.unread) } else { name.to_string() }
}));
let feeds = List::new(feeds).block(block("Feeds".into(), Pane::Feeds)).highlight_style(picked);
frame.render_stateful_widget(feeds, left, &mut self.feed_list);
let items = self.entries.iter().map(|e| {
let mark = format!("{}{}", if e.read { ' ' } else { '•' }, if e.flagged { '★' } else { ' ' });
let line = format!("{mark} {:<10} {}", date(e.published), e.title.as_deref().unwrap_or("(untitled)"));
if e.read { ListItem::new(line) } else { ListItem::new(line).bold() }
});
let shown = if self.unread_only { "unread" } else { "items" };
let title = format!("{} of {} {shown}", self.entries.len(), self.total);
let items = List::new(items).block(block(title, Pane::Items)).highlight_style(picked);
frame.render_stateful_widget(items, top, &mut self.entry_list);
let text = match self.entry() {
Some(e) => {
let mut lines = vec![Line::from(e.title.clone().unwrap_or_default()).bold()];
if let Some(link) = &e.link {
lines.push(Line::from(link.clone()).dim());
}
lines.push(Line::default());
lines
.extend(text_of(e.description.as_deref().unwrap_or("")).lines().map(|l| Line::from(l.to_string())));
lines
}
None => vec![],
};
let text = Paragraph::new(text).wrap(Wrap { trim: false }).scroll((self.scroll, 0));
frame.render_widget(text.block(block("Item".into(), Pane::Text)), bottom);
let help = "j/k move enter open h back m read f flag u unread only r reload q quit";
let foot_text = if self.status.is_empty() { help } else { &self.status };
frame.render_widget(Paragraph::new(foot_text).dim(), foot);
}
}
fn date(t: Option<i64>) -> String {
t.and_then(|t| chrono::DateTime::from_timestamp(t, 0)).map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default()
}
/// An item's HTML as plain text for a terminal: tags dropped, blocks on lines of their own,
/// entities decoded. ipx has already cleaned it, so there is no script or style to skip.
fn text_of(html: &str) -> String {
let mut out = String::new();
let mut rest = html;
loop {
let (text, tag) = match rest.find('<') {
Some(i) => (&rest[..i], Some(&rest[i + 1..])),
None => (rest, None),
};
// HTML's own whitespace is not layout; the tags say where lines break.
if text.starts_with(char::is_whitespace) && !out.is_empty() && !out.ends_with(['\n', ' ']) {
out.push(' ');
}
out.push_str(&decode(&text.split_whitespace().collect::<Vec<_>>().join(" ")));
if text.ends_with(char::is_whitespace) && !text.trim().is_empty() {
out.push(' ');
}
let Some(tag) = tag else { break };
let Some(end) = tag.find('>') else { break };
let (inner, closing) = match tag[..end].strip_prefix('/') {
Some(t) => (t, true),
None => (&tag[..end], false),
};
let name = inner.split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or("").to_ascii_lowercase();
match name.as_str() {
"br" => out.push('\n'),
"li" => {
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !closing {
out.push_str("• ");
}
}
"p" | "div" | "ul" | "ol" | "blockquote" | "pre" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
if !out.is_empty() && !out.ends_with("\n\n") =>
{
out.push_str(if out.ends_with('\n') { "\n" } else { "\n\n" });
}
_ => {}
}
rest = &tag[end + 1..];
}
out.lines().map(str::trim_end).collect::<Vec<_>>().join("\n").trim().to_string()
}
/// HTML's entities, the named ones feeds use and every numbered one.
fn decode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find('&') {
out.push_str(&rest[..i]);
rest = &rest[i..];
let end = rest[..rest.len().min(12)].find(';');
let ch = end.and_then(|end| match &rest[1..end] {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"nbsp" => Some(' '),
"hellip" => Some('…'),
"mdash" => Some('—'),
"ndash" => Some('–'),
"rsquo" => Some('’'),
"lsquo" => Some('‘'),
"rdquo" => Some('”'),
"ldquo" => Some('“'),
n => {
let n = n.strip_prefix('#')?;
let code = match n.strip_prefix(['x', 'X']) {
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
None => n.parse().ok()?,
};
char::from_u32(code)
}
});
match (ch, end) {
(Some(c), Some(end)) => {
out.push(c);
rest = &rest[end + 1..];
}
_ => {
out.push('&');
rest = &rest[1..];
}
}
}
out.push_str(rest);
out
}
fn run(terminal: &mut DefaultTerminal, app: &mut App) -> Result<()> {
loop {
terminal.draw(|f| app.draw(f))?;
// A poll, not a blocking read, so a resize redraws without waiting for a key.
if !event::poll(Duration::from_millis(500))? {
continue;
}
if let Event::Key(k) = event::read()?
&& k.kind == KeyEventKind::Press
&& !app.key(k.code)
{
return Ok(());
}
}
}
fn main() -> Result<()> {
let args = Args::parse();
let http = reqwest::blocking::Client::builder().timeout(Duration::from_secs(30)).build()?;
let api = Api { http, base: args.url, token: args.token };
// Before the terminal is taken over, so a wrong address or token is said plainly.
let feeds = api.feeds().context("could not read your feeds")?;
let mut app = App {
api,
feeds,
feed_list: ListState::default().with_selected(Some(0)),
entries: vec![],
total: 0,
entry_list: ListState::default(),
pane: Pane::Feeds,
scroll: 0,
unread_only: true,
status: String::new(),
};
app.load_entries();
let mut terminal = ratatui::init();
let result = run(&mut terminal, &mut app);
ratatui::restore();
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_of_html() {
let html = "<p>One &amp; two,\n <b>bold</b>.</p><p>Next&nbsp;line<br>broken &#8212; &#x2019;s &bogus; &</p>\
<ul><li>a</li><li>b</li></ul>";
assert_eq!(text_of(html), "One & two, bold.\n\nNext line\nbroken — ’s &bogus; &\n\n• a\n• b");
assert_eq!(text_of("plain"), "plain");
assert_eq!(text_of("a <i>b</i> c"), "a b c");
assert_eq!(text_of("<b>x</b> <i>y</i>"), "x y");
}
}

View File

@@ -5,7 +5,7 @@ Three services, each a container of its own with its own version and changelog (
| Service | What it is | Source | Image | | Service | What it is | Source | Image |
|---|---|---|---| |---|---|---|---|
| ipx-web | Caddy: serves the page, a static site built from `web/`, and passes `/api` and `/media` to ipx on the same address. It asks ipx before it serves the app or the admin page (`/api/page-check`) | `web/`, `web/Caddyfile` | `Dockerfile.web` | | ipx-web | Caddy: serves the page, a static site built from `web/`, and passes `/api` and `/media` to ipx on the same address. It asks ipx before it serves the app or the admin page (`/api/page-check`) | `web/`, `web/Caddyfile` | `Dockerfile.web` |
| ipx | The daemon and CLI, one binary. `ipx daemon` runs a scheduler, a Unix-socket control server and the HTTP API in one process; everything else is a CLI that either does the work itself or hands it to a running daemon | `crates/ipx` | `Dockerfile` | | ipx | The daemon and CLI, one binary, `ipxd`. `ipxd daemon` runs a scheduler, a Unix-socket control server and the HTTP API in one process; everything else is a CLI that either does the work itself or hands it to a running daemon | `crates/ipx` | `Dockerfile` |
| ipx-directory | The directory of public podcasts that ipx servers share. ipx asks it on its people's behalf; the page never talks to it ([directory.md](directory.md)) | `crates/directory` | `Dockerfile.directory` | | ipx-directory | The directory of public podcasts that ipx servers share. ipx asks it on its people's behalf; the page never talks to it ([directory.md](directory.md)) | `crates/directory` | `Dockerfile.directory` |
The Rust source is a Cargo workspace (#202); `crates/feed` (`ipx-feed`) fetches and parses feeds The Rust source is a Cargo workspace (#202); `crates/feed` (`ipx-feed`) fetches and parses feeds
@@ -27,7 +27,8 @@ id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web]
| File | Responsibility | What it replaced in the Python | | File | Responsibility | What it replaced in the Python |
|---|---|---| |---|---|---|
| `crates/ipx/src/main.rs` | The module list and `main`: reads the command line and hands it on | `iPXAgent.py` | | `crates/ipx/src/lib.rs` | The module list and `main`: reads the command line and hands it on | `iPXAgent.py` |
| `crates/ipx/src/main.rs` | `ipxd`, which calls `main`: a library under it keeps the modules' log targets `ipx::...` | — |
| `crates/ipx/src/cli.rs` | The commands, and what each does when no daemon takes it | `iPXAgent.py` | | `crates/ipx/src/cli.rs` | The commands, and what each does when no daemon takes it | `iPXAgent.py` |
| `crates/ipx/src/ctx.rs` | `Ctx`, what every command shares: configuration, database, clients | — | | `crates/ipx/src/ctx.rs` | `Ctx`, what every command shares: configuration, database, clients | — |
| `crates/ipx/src/daemon.rs` | The daemon: worker loop, web server, shutdown, `status` | `iPXAgent.py` | | `crates/ipx/src/daemon.rs` | The daemon: worker loop, web server, shutdown, `status` | `iPXAgent.py` |
@@ -68,6 +69,7 @@ id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web]
| `crates/directory/src/store.rs` | The directory's database: feeds, snapshots, blocked | — | | `crates/directory/src/store.rs` | The directory's database: feeds, snapshots, blocked | — |
| `crates/directory/src/config.rs` | The directory's config file: its tokens and their access | — | | `crates/directory/src/config.rs` | The directory's config file: its tokens and their access | — |
| `crates/directory/src/otel.rs` | Traces for both Rust services: the exporter, `traceparent` in and out | — | | `crates/directory/src/otel.rs` | Traces for both Rust services: the exporter, `traceparent` in and out | — |
| `crates/tui/src/main.rs` | `ipx` (package `ipx-tui`), the reader in a terminal: ratatui over the API, with an API token | — |
| `web/index.html` | The app's markup | — | | `web/index.html` | The app's markup | — |
| `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — | | `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — |
| `web/app.css` | The stylesheet both pages share | — | | `web/app.css` | The stylesheet both pages share | — |
@@ -156,7 +158,7 @@ printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/
**Commands** — `fetch` (optional `feed`, `force`), `reap` (optional `dry_run`), `download` **Commands** — `fetch` (optional `feed`, `force`), `reap` (optional `dry_run`), `download`
(`enclosure`), `status`, `reload` (read the catalogue and settings again from the database, which (`enclosure`), `status`, `reload` (read the catalogue and settings again from the database, which
`ipx add`, `rm` and `import` send after changing them in a process of their own; answered with `ipxd add`, `rm` and `import` send after changing them in a process of their own; answered with
`status`). `status`).
**Events** — `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `download_done`, **Events** — `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `download_done`,

View File

@@ -10,15 +10,15 @@ processes must never download the same thing. `--local` forces the work to happe
| Command | What it does | | Command | What it does |
|---|---| |---|---|
| `ipx list` | Subscriptions and their state | | `ipxd list` | Subscriptions and their state |
| `ipx status` | Counts: feeds, pending, downloaded | | `ipxd status` | Counts: feeds, pending, downloaded |
| `ipx fetch [FEED] [--force]` | Scan everything, or one feed. `--force` ignores the TTL | | `ipxd fetch [FEED] [--force]` | Scan everything, or one feed. `--force` ignores the TTL |
| `ipx add <url> [--folder X] [--keywords a,b] [--list] [--category C]` | Subscribe; the id comes from the feed title. `--list` subscribes no one but keeps it in the catalogue, and so in what this server shares with the directory, when its last subscriber leaves; `--category` is the category the directory files it under when the feed names none. Run for a feed already in the catalogue, these list it or set its category | | `ipxd add <url> [--folder X] [--keywords a,b] [--list] [--category C]` | Subscribe; the id comes from the feed title. `--list` subscribes no one but keeps it in the catalogue, and so in what this server shares with the directory, when its last subscriber leaves; `--category` is the category the directory files it under when the feed names none. Run for a feed already in the catalogue, these list it or set its category |
| `ipx rm <feed>` | Unsubscribe; downloads and history are kept | | `ipxd rm <feed>` | Unsubscribe; downloads and history are kept |
| `ipx import <file.opml>` / `ipx export <file.opml>` | Move subscriptions in or out. Import subscribes the first admin, as the shared web token does; in the web UI it subscribes whoever is signed in | | `ipxd import <file.opml>` / `ipxd export <file.opml>` | Move subscriptions in or out. Import subscribes the first admin, as the shared web token does; in the web UI it subscribes whoever is signed in |
| `ipx reap [--dry-run]` | Run retention now | | `ipxd reap [--dry-run]` | Run retention now |
| `ipx user <add\|list\|passwd\|rm>` | Accounts for the web UI | | `ipxd user <add\|list\|passwd\|rm>` | Accounts for the web UI |
| `ipx daemon [--web ADDR]` | Scheduler, control socket and the HTTP API (the page is ipx-web's) | | `ipxd daemon [--web ADDR]` | Scheduler, control socket and the HTTP API (the page is ipx-web's) |
`add`, `rm` and `import` change the catalogue in a process of their own. With a daemon running they `add`, `rm` and `import` change the catalogue in a process of their own. With a daemon running they
then tell it to read the catalogue again; without that it kept its own copy and wrote it back at then tell it to read the catalogue again; without that it kept its own copy and wrote it back at
@@ -29,11 +29,11 @@ its next change, undoing them. If it does not answer they say so: restart it.
Passwords are read from **stdin**, so they miss the shell history and any `ps` listing. Passwords are read from **stdin**, so they miss the shell history and any `ps` listing.
```sh ```sh
echo -n 'a good password' | ipx user add ray # local account echo -n 'a good password' | ipxd user add ray # local account
ipx user add ray@example.com --no-password # signs in through the proxy only ipxd user add ray@example.com --no-password # signs in through the proxy only
echo -n 'a good password' | ipx user passwd admin # change a password; signs the account out everywhere echo -n 'a good password' | ipxd user passwd admin # change a password; signs the account out everywhere
ipx user list # who exists, and how each signs in ipxd user list # who exists, and how each signs in
ipx user rm sam # account, subscriptions and read state ipxd user rm sam # account, subscriptions and read state
``` ```
The first account created is an admin; later ones are ordinary users. A database with no accounts The first account created is an admin; later ones are ordinary users. A database with no accounts
@@ -43,14 +43,14 @@ config.toml (or `IPX_ADMIN_PASSWORD`), announced in the log — change it.
To avoid even the command line, read it interactively: To avoid even the command line, read it interactively:
```sh ```sh
read -s PW && echo -n "$PW" | ipx user passwd admin read -s PW && echo -n "$PW" | ipxd user passwd admin
``` ```
## Scanning ## Scanning
```sh ```sh
ipx fetch # everything due ipxd fetch # everything due
ipx fetch atp --force # one feed, ignoring its TTL and schedule ipxd fetch atp --force # one feed, ignoring its TTL and schedule
``` ```
A scan: conditional GET (`If-None-Match` / `If-Modified-Since`), parse, record new entries, apply A scan: conditional GET (`If-None-Match` / `If-Modified-Since`), parse, record new entries, apply
@@ -60,8 +60,8 @@ answers `304` and costs one request.
## Retention ## Retention
```sh ```sh
ipx reap --dry-run # what would go, oldest first ipxd reap --dry-run # what would go, oldest first
ipx reap # actually delete ipxd reap # actually delete
``` ```
Files are deleted to get back under `max_total_gb`, oldest first, and items past `max_age_days` Files are deleted to get back under `max_total_gb`, oldest first, and items past `max_age_days`
@@ -72,8 +72,8 @@ stops the next scan fetching it again.
## The daemon ## The daemon
```sh ```sh
ipx daemon # scheduler + socket + HTTP API ipxd daemon # scheduler + socket + HTTP API
ipx daemon --web 0.0.0.0:8099 # override the configured bind for one run ipxd daemon --web 0.0.0.0:8099 # override the configured bind for one run
``` ```
One daemon per socket; a second refuses to start rather than fight over the database. It shuts down One daemon per socket; a second refuses to start rather than fight over the database. It shuts down

View File

@@ -4,7 +4,7 @@ Two places. **config.toml** holds what ipx needs before it reaches its database,
who gets in: where things are (`download_dir`, `socket`, `organize`), `[torrent]` and `[web]`. who gets in: where things are (`download_dir`, `socket`, `organize`), `[torrent]` and `[web]`.
**The database** holds the catalogue of feeds (`[feeds.<id>]` below) and the server settings the **The database** holds the catalogue of feeds (`[feeds.<id>]` below) and the server settings the
admin page edits (`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`, admin page edits (`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`,
`media_types`, `art_cache_mb`). Change those in the web UI, or with `ipx add`, `ipx rm` and `ipx import`; they `media_types`, `art_cache_mb`). Change those in the web UI, or with `ipxd add`, `ipxd rm` and `ipxd import`; they
take effect without a restart. take effect without a restart.
The first time ipx meets a database that holds no catalogue, it takes the feeds and those The first time ipx meets a database that holds no catalogue, it takes the feeds and those
@@ -164,8 +164,8 @@ The app registers each phone through the page as the person signed in there
## `[feeds.<id>]` ## `[feeds.<id>]`
Kept in the database once ipx has moved them in: a feed's settings are changed in the web UI, and Kept in the database once ipx has moved them in: a feed's settings are changed in the web UI, and
feeds come and go with `ipx add`, `ipx rm` and `ipx import`. The table key is the feed id: stable, feeds come and go with `ipxd add`, `ipxd rm` and `ipxd import`. The table key is the feed id: stable,
human-readable, and used in paths and the API. `ipx add` derives it from the feed title. human-readable, and used in paths and the API. `ipxd add` derives it from the feed title.
```toml ```toml
[feeds.atp] [feeds.atp]
@@ -192,7 +192,7 @@ up in Admin, Directory: its address, this server's token and whether to share. T
settings, kept in the database (`directory_url`, `directory_token`, `directory_share`), not here. settings, kept in the database (`directory_url`, `directory_token`, `directory_share`), not here.
With sharing on, this server sends it every feed in the catalogue that is not private, with how With sharing on, this server sends it every feed in the catalogue that is not private, with how
many people here subscribe, and the `category` above for a feed that names none. To offer one with many people here subscribe, and the `category` above for a feed that names none. To offer one with
nobody subscribed, `ipx add --list --category News <url>`: it subscribes no one, and a listed feed nobody subscribed, `ipxd add --list --category News <url>`: it subscribes no one, and a listed feed
stays in the catalogue, and so in what is shared, when its last subscriber leaves, where another stays in the catalogue, and so in what is shared, when its last subscriber leaves, where another
is dropped. A feed nobody is dropped. A feed nobody
subscribes to is checked once a day. One that is dead (failing for 30 days) or quiet (nothing subscribes to is checked once a day. One that is dead (failing for 30 days) or quiet (nothing

View File

@@ -63,7 +63,7 @@ Cloudflare and Authentik were already right. Three things on the ipx side were n
see the next section. see the next section.
3. **The account had the wrong name.** It was made by hand as `rays`, but the header carries 3. **The account had the wrong name.** It was made by hand as `rays`, but the header carries
`rays@sdf1.net`. With `auto_create_users` on, the first visit would have made a second, empty `rays@sdf1.net`. With `auto_create_users` on, the first visit would have made a second, empty
account. `ipx user rename rays rays@sdf1.net` fixed that without losing anything. account. `ipxd user rename rays rays@sdf1.net` fixed that without losing anything.
### The address to trust, and why it is 192.168.64.1 ### The address to trust, and why it is 192.168.64.1
@@ -105,7 +105,7 @@ ordinary user with no feeds. An account made before the proxy can be given the n
send: send:
```sh ```sh
docker exec iPX ipx user rename <old name> <email address> docker exec iPX ipxd user rename <old name> <email address>
``` ```
### Signing out ### Signing out
@@ -225,19 +225,19 @@ Check it: the busybox request under [Check it](#check-it), which sends the email
token from the Docker bridge, now gets `sign in`, and the site still signs you in through Authentik. token from the Docker bridge, now gets `sign in`, and the site still signs you in through Authentik.
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign **Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
in with them until they are given a password (`ipx user passwd <name>`). in with them until they are given a password (`ipxd user passwd <name>`).
--- ---
## Everyday administration ## Everyday administration
```sh ```sh
ipx user list # who exists, how each signs in, and when ipxd user list # who exists, how each signs in, and when
echo -n 'secret123' | ipx user add sam # local account, password on stdin echo -n 'secret123' | ipxd user add sam # local account, password on stdin
ipx user add sam@example.com --no-password # proxy-only account, made ahead of time ipxd user add sam@example.com --no-password # proxy-only account, made ahead of time
ipx user rename sam sam@example.com # give an account the name the proxy sends ipxd user rename sam sam@example.com # give an account the name the proxy sends
echo -n 'newsecret' | ipx user passwd sam # change a password echo -n 'newsecret' | ipxd user passwd sam # change a password
ipx user rm sam # remove the account ipxd user rm sam # remove the account
``` ```
In the container, put `docker exec iPX` in front, and `docker exec -i iPX` for the ones In the container, put `docker exec iPX` in front, and `docker exec -i iPX` for the ones
@@ -276,4 +276,4 @@ rule is on Cloudflare, not in this repository; if the hostname is ever served wi
whole of ipx is on the internet behind bearer tokens alone. whole of ipx is on the internet behind bearer tokens alone.
See also [users.md](users.md) for what several people share, [configuration.md](configuration.md) See also [users.md](users.md) for what several people share, [configuration.md](configuration.md)
for every `[web]` key, and [cli.md](cli.md) for the `ipx user` commands. for every `[web]` key, and [cli.md](cli.md) for the `ipxd user` commands.

View File

@@ -56,13 +56,13 @@ A database with no accounts creates **admin** on the next daemon start, with the
readable by ipx's user alone. The log says where the password is, never what it is. Change it: readable by ipx's user alone. The log says where the password is, never what it is. Change it:
```sh ```sh
echo -n 'a good password' | ipx user passwd admin echo -n 'a good password' | ipxd user passwd admin
``` ```
## Adding someone ## Adding someone
```sh ```sh
echo -n 'their password' | ipx user add sam echo -n 'their password' | ipxd user add sam
``` ```
They sign in at `/login` and start with **no feeds**: subscriptions are per person. Adding a feed They sign in at `/login` and start with **no feeds**: subscriptions are per person. Adding a feed
@@ -107,5 +107,5 @@ and who else is an admin, and read the log, which names everyone's feeds and sig
gets the Settings and Log buttons hidden and a `403` if they ask anyway. gets the Settings and Log buttons hidden and a `403` if they ask anyway.
```sh ```sh
ipx user list # the admin column says who ipxd user list # the admin column says who
``` ```

View File

@@ -43,7 +43,7 @@ rules = [
"Prometheus cannot reach the directory's /metrics.", no_data="Alerting"), "Prometheus cannot reach the directory's /metrics.", no_data="Alerting"),
rule("web-down", "ipx-web is down", 'up{job="ipx-web"}', "lt", 1, "5m", rule("web-down", "ipx-web is down", 'up{job="ipx-web"}', "lt", 1, "5m",
"Prometheus cannot reach Caddy's /metrics: the page is likely down too.", no_data="Alerting"), "Prometheus cannot reach Caddy's /metrics: the page is likely down too.", no_data="Alerting"),
# The worker: the daemon answers `ipx status` while it is stuck, so only these say so. # The worker: the daemon answers `ipxd status` while it is stuck, so only these say so.
rule("scan-stale", "ipx's scans have stopped", 'max(ipx_last_scan_age_seconds{job="ipx"})', "gt", 900, "5m", rule("scan-stale", "ipx's scans have stopped", 'max(ipx_last_scan_age_seconds{job="ipx"})', "gt", 900, "5m",
"No scan has finished for over 15 minutes; scans run at least every 10. The worker is stuck: its job and trace say on what."), "No scan has finished for over 15 minutes; scans run at least every 10. The worker is stuck: its job and trace say on what."),
rule("job-long", "ipx's worker is stuck on one job", 'max(ipx_worker_job_seconds{job="ipx"})', "gt", 1800, "1m", rule("job-long", "ipx's worker is stuck on one job", 'max(ipx_worker_job_seconds{job="ipx"})', "gt", 1800, "1m",

View File

@@ -19,7 +19,7 @@ PROM = {"type": "prometheus", "uid": "${prometheus}"}
SEL = '{container="iPX"}' SEL = '{container="iPX"}'
# ipx logs one JSON object a line (IPX_LOG_FORMAT=json). Each scan and download event carries its # ipx logs one JSON object a line (IPX_LOG_FORMAT=json). Each scan and download event carries its
# fields (ev, feed, new, bytes, msg, error.type, ...); a failed request its method, path, route, # fields (ev, feed, new, bytes, msg, error.type, ...); a failed request its method, path, route,
# status and duration_ms. Events are logged under ipx::scan, `ipx status`'s answer under ipx::io, # status and duration_ms. Events are logged under ipx::scan, `ipxd status`'s answer under ipx::io,
# so the filter is on the ev field, not the target. # so the filter is on the ev field, not the target.
EV = SEL + ' |= "\\"ev\\":\\"" | json | __error__="" | ev != ""' EV = SEL + ' |= "\\"ev\\":\\"" | json | __error__="" | ev != ""'
# Each request ipx answers, the event stream aside, as a histogram (#364): its line in the log is # Each request ipx answers, the event stream aside, as a histogram (#364): its line in the log is

View File

@@ -7,7 +7,7 @@ cd "$(dirname "$0")/.."
case "${1:-}" in case "${1:-}" in
ipx) name=ipx; ver=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ipx/Cargo.toml | head -1) ipx) name=ipx; ver=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ipx/Cargo.toml | head -1)
tag=v$ver; log=crates/ipx/CHANGELOG.md; from=/usr/local/bin/ipx ;; tag=v$ver; log=crates/ipx/CHANGELOG.md; from=/usr/local/bin/ipxd ;;
directory) name=ipx-directory; ver=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/directory/Cargo.toml | head -1) directory) name=ipx-directory; ver=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/directory/Cargo.toml | head -1)
tag=directory-v$ver; log=crates/directory/CHANGELOG.md; from=/usr/local/bin/ipx-directory ;; tag=directory-v$ver; log=crates/directory/CHANGELOG.md; from=/usr/local/bin/ipx-directory ;;
web) name=ipx-web; ver=$(node -p 'require("./package.json").version') web) name=ipx-web; ver=$(node -p 'require("./package.json").version')
@@ -31,7 +31,7 @@ if [ "$name" = ipx-web ]; then
arch=any arch=any
else else
docker cp -q "$c:$from" "$dir/" docker cp -q "$c:$from" "$dir/"
"$dir/$name" --version | grep -qF " $ver" || { echo "$image is not $name $ver" >&2; exit 1; } "$dir/$(basename "$from")" --version | grep -qF " $ver" || { echo "$image is not $name $ver" >&2; exit 1; }
arch=linux-$(uname -m) arch=linux-$(uname -m)
fi fi
cp LICENSE README.md "$dir/"; cp "$log" "$dir/CHANGELOG.md" cp LICENSE README.md "$dir/"; cp "$log" "$dir/CHANGELOG.md"

View File

@@ -338,7 +338,7 @@ export const ipx: Api = {
{ {
method: 'GET', path: '/api/status', slug: 'get-status', method: 'GET', path: '/api/status', slug: 'get-status',
summary: 'How many feeds, files waiting and files downloaded, and the version.', summary: 'How many feeds, files waiting and files downloaded, and the version.',
doc: '<p>The numbers <code>ipx status</code> prints, for a dashboard.</p>', doc: '<p>The numbers <code>ipxd status</code> prints, for a dashboard.</p>',
returns: 'Status', example: ipxCurl('GET', '/api/status'), returns: 'Status', example: ipxCurl('GET', '/api/status'),
}, },
{ {

View File

@@ -3,13 +3,13 @@
<div class="scroll"><table> <div class="scroll"><table>
<thead><tr><th>Command</th><th>What it does</th></tr></thead> <thead><tr><th>Command</th><th>What it does</th></tr></thead>
<tbody> <tbody>
<tr><td><code>ipx list</code></td><td>Every podcast and its state</td></tr> <tr><td><code>ipxd list</code></td><td>Every podcast and its state</td></tr>
<tr><td><code>ipx status</code></td><td>How many podcasts, how many files waiting and downloaded</td></tr> <tr><td><code>ipxd status</code></td><td>How many podcasts, how many files waiting and downloaded</td></tr>
<tr><td><code>ipx fetch [id] [--force]</code></td><td>Check everything that is due, or one podcast; <code>--force</code> checks it even if it is not due</td></tr> <tr><td><code>ipxd fetch [id] [--force]</code></td><td>Check everything that is due, or one podcast; <code>--force</code> checks it even if it is not due</td></tr>
<tr><td><code>ipx add &lt;url&gt;</code>, <code>ipx rm &lt;id&gt;</code></td><td>Add a podcast, or take it out</td></tr> <tr><td><code>ipxd add &lt;url&gt;</code>, <code>ipxd rm &lt;id&gt;</code></td><td>Add a podcast, or take it out</td></tr>
<tr><td><code>ipx import &lt;file&gt;</code>, <code>ipx export &lt;file&gt;</code></td><td>OPML in or out</td></tr> <tr><td><code>ipxd import &lt;file&gt;</code>, <code>ipxd export &lt;file&gt;</code></td><td>OPML in or out</td></tr>
<tr><td><code>ipx reap [--dry-run]</code></td><td>Clear old files now, to stay within the space you set</td></tr> <tr><td><code>ipxd reap [--dry-run]</code></td><td>Clear old files now, to stay within the space you set</td></tr>
<tr><td><code>ipx user add|list|passwd|rm</code></td><td>Accounts</td></tr> <tr><td><code>ipxd user add|list|passwd|rm</code></td><td>Accounts</td></tr>
</tbody> </tbody>
</table></div> </table></div>
<h2>The control socket</h2> <h2>The control socket</h2>

View File

@@ -35,12 +35,12 @@ docker build -f Dockerfile.web -t ipx-web .</code></pre>
- ipx</code></pre> - ipx</code></pre>
<pre><code>docker compose up -d</code></pre> <pre><code>docker compose up -d</code></pre>
<p>ipx has no port of its own: everything reaches it through ipx-web, on port 8099. Open <code>http://&lt;your server&gt;:8099</code> and sign in as <strong>admin</strong> with the password <strong>ipodderx</strong>, then change it:</p> <p>ipx has no port of its own: everything reaches it through ipx-web, on port 8099. Open <code>http://&lt;your server&gt;:8099</code> and sign in as <strong>admin</strong> with the password <strong>ipodderx</strong>, then change it:</p>
<pre><code>echo -n 'a good password' | docker exec -i ipx ipx user passwd admin</code></pre> <pre><code>echo -n 'a good password' | docker exec -i ipx ipxd user passwd admin</code></pre>
<p>The password is read from what you pipe in, so it stays out of your shell's history.</p> <p>The password is read from what you pipe in, so it stays out of your shell's history.</p>
<h2>Adding people</h2> <h2>Adding people</h2>
<p>An admin adds people in Admin, Accounts, or from the command line:</p> <p>An admin adds people in Admin, Accounts, or from the command line:</p>
<pre><code>echo -n 'their password' | docker exec -i ipx ipx user add sam</code></pre> <pre><code>echo -n 'their password' | docker exec -i ipx ipxd user add sam</code></pre>
<p>Each person starts with no podcasts and subscribes to their own. Two people subscribing to the same show cost nothing extra: it is fetched once and each file is kept once.</p> <p>Each person starts with no podcasts and subscribes to their own. Two people subscribing to the same show cost nothing extra: it is fetched once and each file is kept once.</p>
<h2>Settings</h2> <h2>Settings</h2>

View File

@@ -8,7 +8,7 @@
// It builds a release binary (debug Argon2 alone takes a second a sign-in, which would measure // It builds a release binary (debug Argon2 alone takes a second a sign-in, which would measure
// the build, not ipx), serves generated feeds from this process, starts a daemon on its own // the build, not ipx), serves generated feeds from this process, starts a daemon on its own
// config and data under /tmp/ipx-load, wiped first, seeds listeners, then runs each k6 script. // config and data under /tmp/ipx-load, wiped first, seeds listeners, then runs each k6 script.
// While each runs, `ipx status` is run every second, and the run fails if any answer takes 5s. // While each runs, `ipxd status` is run every second, and the run fails if any answer takes 5s.
// //
// The daemon is on SQLite unless --postgres, which puts it in IPX_TEST_DATABASE_URL, the // The daemon is on SQLite unless --postgres, which puts it in IPX_TEST_DATABASE_URL, the
// database the Rust tests use on Postgres too (ipodderx_test on the server production's is on, in // database the Rust tests use on Postgres too (ipodderx_test on the server production's is on, in
@@ -30,7 +30,7 @@ const USERS = Number(process.env.IPX_LOAD_USERS || 100);
const MEDIA = 4; const MEDIA = 4;
const ITEMS = 20; const ITEMS = 20;
const BASE = `http://127.0.0.1:${WEB}`; const BASE = `http://127.0.0.1:${WEB}`;
const bin = path.join(repo, 'target/release/ipx'); const bin = path.join(repo, 'target/release/ipxd');
const SCRIPTS = ['browse', 'listening', 'media', 'signin']; const SCRIPTS = ['browse', 'listening', 'media', 'signin'];
const POSTGRES = process.argv.includes('--postgres'); const POSTGRES = process.argv.includes('--postgres');
const PG_URL = process.env.IPX_TEST_DATABASE_URL || ''; const PG_URL = process.env.IPX_TEST_DATABASE_URL || '';
@@ -202,13 +202,13 @@ async function seed() {
if (entries.flatMap(e => e.enclosures).filter(x => x.path).length >= MEDIA) break; if (entries.flatMap(e => e.enclosures).filter(x => x.path).length >= MEDIA) break;
if (Date.now() - t > 120_000) throw new Error(`the listener's ${MEDIA} files did not download in 120s`); if (Date.now() - t > 120_000) throw new Error(`the listener's ${MEDIA} files did not download in 120s`);
} }
// A password, for the sign-in test; the CLI hashes it as `ipx user add` always does. // A password, for the sign-in test; the CLI hashes it as `ipxd user add` always does.
execFileSync(bin, ['user', 'add', 'piper'], { input: 'piperpassword', env }); execFileSync(bin, ['user', 'add', 'piper'], { input: 'piperpassword', env });
} }
// ---- the run -------------------------------------------------------------------------------- // ---- the run --------------------------------------------------------------------------------
/// `ipx status` once a second until stopped: the slowest answer, and any that failed. Not /// `ipxd status` once a second until stopped: the slowest answer, and any that failed. Not
/// spawnSync: blocked in it, this process stops draining the daemon's log, the pipe fills, and /// spawnSync: blocked in it, this process stops draining the daemon's log, the pipe fills, and
/// the daemon stalls writing its next line, which is the test measuring itself. /// the daemon stalls writing its next line, which is the test measuring itself.
function watchHealth() { function watchHealth() {
@@ -269,7 +269,7 @@ function k6(script: string) {
const stop = watchHealth(); const stop = watchHealth();
const code = await k6(script); const code = await k6(script);
const health = stop(); const health = stop();
console.log(`ipx status: slowest ${health.slowest}ms, ${health.failed} failed`); console.log(`ipxd status: slowest ${health.slowest}ms, ${health.failed} failed`);
if (code !== 0) { failed++; console.log(`${script}: thresholds failed (k6 exit ${code})`); } if (code !== 0) { failed++; console.log(`${script}: thresholds failed (k6 exit ${code})`); }
if (health.failed || health.slowest >= 5000) { failed++; console.log(`${script}: the healthcheck would have failed`); } if (health.failed || health.slowest >= 5000) { failed++; console.log(`${script}: the healthcheck would have failed`); }
} }

View File

@@ -1028,7 +1028,7 @@ test('an export from one account imports into another', async ({ page, browser }
await ctx.close(); await ctx.close();
}); });
test('ipx import subscribes the admin, and ipx export writes the feeds out', async () => { test('ipxd import subscribes the admin, and ipxd export writes the feeds out', async () => {
// Its own config and database. The CLI works in-process, and the suite's running daemon // Its own config and database. The CLI works in-process, and the suite's running daemon
// reads config.toml once at start, so it would not see what the CLI added anyway. // reads config.toml once at start, so it would not see what the CLI added anyway.
const dir = path.join(setup.root, 'cli'); const dir = path.join(setup.root, 'cli');
@@ -1232,7 +1232,7 @@ test('the feed list keeps the icons it has drawn when a feed\'s new row comes in
expect(kept).toEqual({ drawn: true, redrawn: true, same: true }); expect(kept).toEqual({ drawn: true, redrawn: true, same: true });
}); });
test('ipx add and rm while the daemon runs reach it, and its next change keeps them (#120)', async ({ page }) => { test('ipxd add and rm while the daemon runs reach it, and its next change keeps them (#120)', async ({ page }) => {
const env = { ...process.env, IPX_CONFIG: `${setup.root}/config/config.toml`, IPX_DATA_DIR: `${setup.root}/data` }; const env = { ...process.env, IPX_CONFIG: `${setup.root}/config/config.toml`, IPX_DATA_DIR: `${setup.root}/data` };
const ipx = (...args: string[]) => execFileSync(setup.IPX_BIN, args, { env, encoding: 'utf8' }); const ipx = (...args: string[]) => execFileSync(setup.IPX_BIN, args, { env, encoding: 'utf8' });
// By title: the directory names a feed by its address, not by its id here (#205). // By title: the directory names a feed by its address, not by its id here (#205).

View File

@@ -1,5 +1,5 @@
<?xml version="1.0"?> <?xml version="1.0"?>
<rss version="2.0"><channel><title>Command Line Show</title><link>http://127.0.0.1:8792/</link> <rss version="2.0"><channel><title>Command Line Show</title><link>http://127.0.0.1:8792/</link>
<description>Added with ipx add while the daemon runs.</description> <description>Added with ipxd add while the daemon runs.</description>
<item><title>From the Command Line</title><guid>cli-1</guid><description>x</description></item> <item><title>From the Command Line</title><guid>cli-1</guid><description>x</description></item>
</channel></rss> </channel></rss>

View File

@@ -92,7 +92,7 @@ token_sha256 = "${crypto.createHash('sha256').update(DIRECTORY_TOKEN).digest('he
/// suite runs that build's ipx and ipx-directory, which write their coverage profiles there, the /// suite runs that build's ipx and ipx-directory, which write their coverage profiles there, the
/// tests' own `ipx` commands included; without it, the debug build it makes itself. /// tests' own `ipx` commands included; without it, the debug build it makes itself.
const API_COVERAGE = process.env.IPX_API_COVERAGE ?? ''; const API_COVERAGE = process.env.IPX_API_COVERAGE ?? '';
const IPX_BIN = API_COVERAGE ? path.join(API_COVERAGE, 'debug', 'ipx') : './target/debug/ipx'; const IPX_BIN = API_COVERAGE ? path.join(API_COVERAGE, 'debug', 'ipxd') : './target/debug/ipxd';
const DIRECTORY_BIN = API_COVERAGE ? path.join(API_COVERAGE, 'debug', 'ipx-directory') : './target/debug/ipx-directory'; const DIRECTORY_BIN = API_COVERAGE ? path.join(API_COVERAGE, 'debug', 'ipx-directory') : './target/debug/ipx-directory';
export { prepare, root, TOKEN, DIRECTORY, DIRECTORY_TOKEN, API_COVERAGE, IPX_BIN, DIRECTORY_BIN }; export { prepare, root, TOKEN, DIRECTORY, DIRECTORY_TOKEN, API_COVERAGE, IPX_BIN, DIRECTORY_BIN };