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:
@@ -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::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::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
|
||||
updated), `download_done` (bytes), `download_error` (msg, url),
|
||||
`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`,
|
||||
`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.
|
||||
@@ -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_feed_fetches_total`, `ipx_feed_bytes_total` | counters: feeds fetched (304s included) and the bytes of their bodies, read whole, so what scans allocate |
|
||||
| `ipx_uptime_seconds`, `ipx_build_info{version}` | since the daemon started; what runs |
|
||||
| `ipx_worker_busy`, `ipx_worker_job_seconds{kind}` | the worker's job now (`scan`, `reap`, `download`, `status`, `reload`) and for how long; `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_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 |
|
||||
@@ -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).
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -10,7 +10,9 @@ if ! cargo fmt --all --check >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
pkgs="ipx-directory ipx"
|
||||
elif echo "$staged" | grep -q '^crates/ipx/'; then
|
||||
|
||||
13
CLAUDE.md
13
CLAUDE.md
@@ -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
|
||||
fails whenever root is logged out, because elogind removes `/run/user/0`, where containerd puts
|
||||
each exec's state. Whether ipx is well is Prometheus's question (`up`, the worker's job and time)
|
||||
and `ipx status` against the control socket, which says the daemon answers and can read its
|
||||
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
|
||||
job, so a worker stuck on one job still answers it. The containers restart on their own after a
|
||||
reboot.
|
||||
@@ -239,7 +239,8 @@ session was stopped. So:
|
||||
| `crates/ipx/` | `cargo test -p ipx`; the browser tests too if the API changed |
|
||||
| `crates/directory/` | `cargo test -p ipx-directory`, then `-p ipx` |
|
||||
| `crates/feed/` | `cargo test -p ipx-feed`, then `-p ipx-directory`, then `-p ipx` |
|
||||
| `Cargo.toml`, `Cargo.lock` | all three, one at a time |
|
||||
| `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
|
||||
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.
|
||||
* Each threshold is a budget well above what the request takes now: it is there to catch a query
|
||||
that has started asking once per feed, or writers queueing on a lock, not a slow minute.
|
||||
* `ipx status` 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.
|
||||
* 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
|
||||
@@ -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
|
||||
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
|
||||
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.
|
||||
* **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`
|
||||
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
|
||||
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
|
||||
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`
|
||||
|
||||
354
Cargo.lock
generated
354
Cargo.lock
generated
@@ -115,6 +115,15 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.2"
|
||||
@@ -507,6 +516,12 @@ version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "by_address"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
@@ -519,6 +534,15 @@ version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.5"
|
||||
@@ -650,6 +674,20 @@ dependencies = [
|
||||
"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]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.41"
|
||||
@@ -679,6 +717,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -768,6 +815,33 @@ version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
@@ -994,6 +1068,7 @@ version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
@@ -1063,6 +1138,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "dontfrag"
|
||||
version = "1.0.1"
|
||||
@@ -1507,6 +1591,11 @@ name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
@@ -1843,6 +1932,28 @@ dependencies = [
|
||||
"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]]
|
||||
name = "intervaltree"
|
||||
version = "0.2.7"
|
||||
@@ -1945,6 +2056,19 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipx-tui"
|
||||
version = "0.1.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"ratatui",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -2120,6 +2244,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -2483,12 +2618,33 @@ dependencies = [
|
||||
"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]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -2504,6 +2660,15 @@ version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
@@ -2639,6 +2804,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -2806,6 +2972,15 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -2921,6 +3096,39 @@ dependencies = [
|
||||
"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]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -3364,6 +3572,72 @@ dependencies = [
|
||||
"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]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
@@ -3563,6 +3837,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustls"
|
||||
version = "0.23.46"
|
||||
@@ -4020,6 +4307,27 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
@@ -4298,6 +4606,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.11.0"
|
||||
@@ -4344,6 +4658,21 @@ name = "strum"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "subtle"
|
||||
@@ -4496,7 +4825,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
@@ -4887,6 +5218,29 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["crates/ipx", "crates/feed", "crates/directory"]
|
||||
members = ["crates/ipx", "crates/feed", "crates/directory", "crates/tui"]
|
||||
resolver = "3"
|
||||
|
||||
# Each service has its own version, in its own Cargo.toml (ipx, ipx-directory) or package.json
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -13,17 +13,19 @@ COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/ipx/Cargo.toml crates/ipx/
|
||||
COPY crates/feed/Cargo.toml crates/feed/
|
||||
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/directory/src/main.rs && touch crates/directory/src/lib.rs \
|
||||
&& echo 'fn main(){}' > crates/tui/src/main.rs \
|
||||
&& 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
|
||||
# ipx builds the directory crate in, whose public pages serve these (#215).
|
||||
COPY web/logo.svg web/logo-dark.svg web/favicon.png ./web/
|
||||
# cargo skips a rebuild if mtimes look untouched; make sure it does not.
|
||||
RUN touch crates/ipx/src/main.rs crates/feed/src/lib.rs && cargo build --release --locked -p ipx
|
||||
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
|
||||
# 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 \
|
||||
ca-certificates gosu ffmpeg \
|
||||
&& 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/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
@@ -46,4 +48,4 @@ VOLUME ["/config", "/data", "/downloads"]
|
||||
EXPOSE 8099/tcp 6881/tcp 6881/udp
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["ipx", "daemon"]
|
||||
CMD ["ipxd", "daemon"]
|
||||
|
||||
@@ -44,7 +44,7 @@ From source:
|
||||
|
||||
```sh
|
||||
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
|
||||
@@ -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:
|
||||
|
||||
```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.
|
||||
@@ -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/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/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 |
|
||||
|
||||
@@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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 scan that finishes, a fifth of its lines; the metrics count scans, and the page's log view still shows them (#363).
|
||||
|
||||
|
||||
@@ -4,6 +4,12 @@ version = "1.5.2-dev"
|
||||
edition.workspace = true
|
||||
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]
|
||||
workspace = true
|
||||
|
||||
|
||||
@@ -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
|
||||
/// 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
|
||||
/// feed first, to say its own way why an address is not one.
|
||||
pub async fn add_to_catalogue(
|
||||
@@ -151,7 +151,7 @@ pub(crate) async fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
||||
.await?
|
||||
.into_iter()
|
||||
.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 (added, had) = lists::subscribe_opml(ctx, lists::opml_feeds(&doc)?, admin.id).await?;
|
||||
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) {
|
||||
tracing::warn!(
|
||||
"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);
|
||||
@@ -213,13 +213,13 @@ pub(crate) async fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
|
||||
pub(crate) async fn list(ctx: &Ctx) -> Result<()> {
|
||||
let cfg = ctx.cfg();
|
||||
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(());
|
||||
}
|
||||
for (id, feed) in &cfg.feeds {
|
||||
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
|
||||
// "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!(" id {id}");
|
||||
println!(" url {}", feed.url);
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "ipx", version, about = "A headless podcatcher")]
|
||||
#[command(name = "ipxd", version, about = "A headless podcatcher")]
|
||||
pub(crate) struct Cli {
|
||||
/// Config file (default: $XDG_CONFIG_HOME/ipx/config.toml)
|
||||
#[arg(long, global = true)]
|
||||
@@ -81,7 +81,7 @@ pub(crate) enum Command {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
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 {
|
||||
name: String,
|
||||
/// 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)?;
|
||||
let pw = buf.trim_end_matches(['\n', '\r']).to_string();
|
||||
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)
|
||||
};
|
||||
@@ -138,7 +138,7 @@ pub(crate) async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd, mut stdin: impl std::
|
||||
UserCmd::List => {
|
||||
let users = ctx.db.users().await?;
|
||||
if users.is_empty() {
|
||||
println!("no accounts yet: ipx user add <name>");
|
||||
println!("no accounts yet: ipxd user add <name>");
|
||||
}
|
||||
for u in users {
|
||||
let added = u
|
||||
|
||||
@@ -219,7 +219,7 @@ pub struct Feed {
|
||||
/// Preferred over `password`: name of an env var holding the password.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub listed: bool,
|
||||
|
||||
@@ -48,7 +48,7 @@ pub struct Ctx {
|
||||
/// 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
|
||||
/// 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
|
||||
// database rather than memory if that ever happens.
|
||||
pub cfg_write: tokio::sync::Mutex<()>,
|
||||
|
||||
@@ -24,7 +24,7 @@ pub(crate) async fn reload(ctx: &Ctx) -> Result<()> {
|
||||
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.
|
||||
pub(crate) async fn status(ctx: &Ctx) -> Event {
|
||||
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())?;
|
||||
ctx.db.create_user("admin", Some(&crate::auth::hash_password(&password)?), /* admin */ true).await?;
|
||||
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
|
||||
/// 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
|
||||
/// 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.
|
||||
pub(crate) async fn until_next_scan(ctx: &Ctx) -> std::time::Duration {
|
||||
let next = async {
|
||||
@@ -398,7 +398,7 @@ mod tests {
|
||||
let held = with("kept-in-memory");
|
||||
let ctx = test_ctx(held.clone()).await;
|
||||
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();
|
||||
reload(&ctx).await.unwrap();
|
||||
let urls: Vec<String> = ctx.cfg().feeds.values().map(|f| f.url.clone()).collect();
|
||||
|
||||
@@ -43,7 +43,7 @@ impl Drop for Db {
|
||||
///
|
||||
/// 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`
|
||||
/// 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.
|
||||
///
|
||||
/// 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
|
||||
// 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.
|
||||
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
|
||||
@@ -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
|
||||
// open, its password exchange most of it, and a page load asked for more than were idle, so
|
||||
// the feed list and item list waited on new ones, 300-900 ms where their queries took 5-30.
|
||||
// Not a command such as `ipx status`, nor a test, which would hold twenty for one question.
|
||||
// Not a command such as `ipxd status`, nor a test, which would hold twenty for one question.
|
||||
if keep_open && is_postgres(location) {
|
||||
opts.min_connections(20);
|
||||
}
|
||||
@@ -408,7 +408,7 @@ pub struct Managed {
|
||||
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)]
|
||||
pub struct FeedSummary {
|
||||
pub title: Option<String>,
|
||||
@@ -438,7 +438,7 @@ impl Db {
|
||||
}
|
||||
let orm = connect(location, keep_open).await?;
|
||||
if !is_postgres(location) {
|
||||
// WAL, so the healthcheck's `ipx status` reads while the daemon writes. It is a
|
||||
// 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
|
||||
// a busy daemon, so it is made only when the file is not already in WAL.
|
||||
let mode =
|
||||
|
||||
@@ -212,7 +212,7 @@ pub enum Command {
|
||||
enclosure: i64,
|
||||
},
|
||||
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
|
||||
/// 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.
|
||||
@@ -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.
|
||||
if matches!(e, Event::Status { .. }) {
|
||||
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
|
||||
/// 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
|
||||
/// 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.
|
||||
pub type StatusFn = Arc<dyn Fn(Command) -> Pin<Box<dyn Future<Output = Event> + Send>> + Send + Sync>;
|
||||
|
||||
|
||||
130
crates/ipx/src/lib.rs
Normal file
130
crates/ipx/src/lib.rs
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
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]
|
||||
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
|
||||
fn main() -> anyhow::Result<()> {
|
||||
ipx::main()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
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();
|
||||
|
||||
add_one_cfg(&mut cfg, "http://x/f.xml", None, vec![])
|
||||
|
||||
@@ -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
|
||||
/// 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 {
|
||||
let admins: Vec<i64> = users.iter().filter(|u| u.is_admin).map(|u| u.id).collect();
|
||||
admins == [id]
|
||||
@@ -798,7 +798,7 @@ async fn add_user(
|
||||
if state.ctx.db.user_by_name(&name).await?.is_some() {
|
||||
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() {
|
||||
None
|
||||
} 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);
|
||||
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).
|
||||
if !crate::directory::listed_art(&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 })))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
async fn status(State(state): State<WebState>) -> Response {
|
||||
match crate::daemon::status(&state.ctx).await {
|
||||
|
||||
@@ -93,7 +93,7 @@ url = "http://127.0.0.1:{feed_port}/feed.xml"
|
||||
}
|
||||
|
||||
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)
|
||||
.env("IPX_CONFIG", self.root.join("config.toml"))
|
||||
.env("IPX_DATA_DIR", self.root.join("data"))
|
||||
|
||||
14
crates/tui/CHANGELOG.md
Normal file
14
crates/tui/CHANGELOG.md
Normal 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
21
crates/tui/Cargo.toml
Normal 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
27
crates/tui/README.md
Normal 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
451
crates/tui/src/main.rs
Normal 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 & two,\n <b>bold</b>.</p><p>Next line<br>broken — ’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");
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ Three services, each a container of its own with its own version and changelog (
|
||||
| Service | What it is | Source | Image |
|
||||
|---|---|---|---|
|
||||
| ipx-web | Caddy: serves the page, a static site built from `web/`, and passes `/api` and `/media` to ipx on the same address. It asks ipx before it serves the app or the admin page (`/api/page-check`) | `web/`, `web/Caddyfile` | `Dockerfile.web` |
|
||||
| ipx | The daemon and CLI, one binary. `ipx daemon` runs a scheduler, a Unix-socket control server and the HTTP API in one process; everything else is a CLI that either does the work itself or hands it to a running daemon | `crates/ipx` | `Dockerfile` |
|
||||
| ipx | 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` |
|
||||
|
||||
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 |
|
||||
|---|---|---|
|
||||
| `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/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` |
|
||||
@@ -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/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/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/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — |
|
||||
| `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`
|
||||
(`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`).
|
||||
|
||||
**Events** — `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `download_done`,
|
||||
|
||||
42
docs/cli.md
42
docs/cli.md
@@ -10,15 +10,15 @@ processes must never download the same thing. `--local` forces the work to happe
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `ipx list` | Subscriptions and their state |
|
||||
| `ipx status` | Counts: feeds, pending, downloaded |
|
||||
| `ipx 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 |
|
||||
| `ipx rm <feed>` | Unsubscribe; downloads and history are kept |
|
||||
| `ipx import <file.opml>` / `ipx export <file.opml>` | Move subscriptions in or out. Import subscribes the first admin, as the shared web token does; in the web UI it subscribes whoever is signed in |
|
||||
| `ipx reap [--dry-run]` | Run retention now |
|
||||
| `ipx 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 list` | Subscriptions and their state |
|
||||
| `ipxd status` | Counts: feeds, pending, downloaded |
|
||||
| `ipxd fetch [FEED] [--force]` | Scan everything, or one feed. `--force` ignores the TTL |
|
||||
| `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 |
|
||||
| `ipxd rm <feed>` | Unsubscribe; downloads and history are kept |
|
||||
| `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 |
|
||||
| `ipxd reap [--dry-run]` | Run retention now |
|
||||
| `ipxd user <add\|list\|passwd\|rm>` | Accounts for the web UI |
|
||||
| `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
|
||||
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.
|
||||
|
||||
```sh
|
||||
echo -n 'a good password' | ipx user add ray # local account
|
||||
ipx 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
|
||||
ipx user list # who exists, and how each signs in
|
||||
ipx user rm sam # account, subscriptions and read state
|
||||
echo -n 'a good password' | ipxd user add ray # local account
|
||||
ipxd user add ray@example.com --no-password # signs in through the proxy only
|
||||
echo -n 'a good password' | ipxd user passwd admin # change a password; signs the account out everywhere
|
||||
ipxd user list # who exists, and how each signs in
|
||||
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
|
||||
@@ -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:
|
||||
|
||||
```sh
|
||||
read -s PW && echo -n "$PW" | ipx user passwd admin
|
||||
read -s PW && echo -n "$PW" | ipxd user passwd admin
|
||||
```
|
||||
|
||||
## Scanning
|
||||
|
||||
```sh
|
||||
ipx fetch # everything due
|
||||
ipx fetch atp --force # one feed, ignoring its TTL and schedule
|
||||
ipxd fetch # everything due
|
||||
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
|
||||
@@ -60,8 +60,8 @@ answers `304` and costs one request.
|
||||
## Retention
|
||||
|
||||
```sh
|
||||
ipx reap --dry-run # what would go, oldest first
|
||||
ipx reap # actually delete
|
||||
ipxd reap --dry-run # what would go, oldest first
|
||||
ipxd reap # actually delete
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```sh
|
||||
ipx daemon # scheduler + socket + HTTP API
|
||||
ipx daemon --web 0.0.0.0:8099 # override the configured bind for one run
|
||||
ipxd daemon # scheduler + socket + HTTP API
|
||||
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
|
||||
|
||||
@@ -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]`.
|
||||
**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`,
|
||||
`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.
|
||||
|
||||
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>]`
|
||||
|
||||
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,
|
||||
human-readable, and used in paths and the API. `ipx add` derives it from the feed title.
|
||||
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. `ipxd add` derives it from the feed title.
|
||||
|
||||
```toml
|
||||
[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.
|
||||
With sharing on, this server sends it every feed in the catalogue that is not private, with how
|
||||
many people here subscribe, and the `category` above for a feed that names none. To offer one with
|
||||
nobody subscribed, `ipx add --list --category News <url>`: it subscribes no one, and a listed feed
|
||||
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
|
||||
is dropped. A feed nobody
|
||||
subscribes to is checked once a day. One that is dead (failing for 30 days) or quiet (nothing
|
||||
|
||||
20
docs/sso.md
20
docs/sso.md
@@ -63,7 +63,7 @@ Cloudflare and Authentik were already right. Three things on the ipx side were n
|
||||
see the next section.
|
||||
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
|
||||
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
|
||||
|
||||
@@ -105,7 +105,7 @@ ordinary user with no feeds. An account made before the proxy can be given the n
|
||||
send:
|
||||
|
||||
```sh
|
||||
docker exec iPX ipx user rename <old name> <email address>
|
||||
docker exec iPX ipxd user rename <old name> <email address>
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
**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
|
||||
|
||||
```sh
|
||||
ipx user list # who exists, how each signs in, and when
|
||||
echo -n 'secret123' | ipx user add sam # local account, password on stdin
|
||||
ipx 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
|
||||
echo -n 'newsecret' | ipx user passwd sam # change a password
|
||||
ipx user rm sam # remove the account
|
||||
ipxd user list # who exists, how each signs in, and when
|
||||
echo -n 'secret123' | ipxd user add sam # local account, password on stdin
|
||||
ipxd user add sam@example.com --no-password # proxy-only account, made ahead of time
|
||||
ipxd user rename sam sam@example.com # give an account the name the proxy sends
|
||||
echo -n 'newsecret' | ipxd user passwd sam # change a password
|
||||
ipxd user rm sam # remove the account
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
```sh
|
||||
echo -n 'a good password' | ipx user passwd admin
|
||||
echo -n 'a good password' | ipxd user passwd admin
|
||||
```
|
||||
|
||||
## Adding someone
|
||||
|
||||
```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
|
||||
@@ -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.
|
||||
|
||||
```sh
|
||||
ipx user list # the admin column says who
|
||||
ipxd user list # the admin column says who
|
||||
```
|
||||
|
||||
@@ -43,7 +43,7 @@ rules = [
|
||||
"Prometheus cannot reach the directory's /metrics.", no_data="Alerting"),
|
||||
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"),
|
||||
# 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",
|
||||
"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",
|
||||
|
||||
@@ -19,7 +19,7 @@ PROM = {"type": "prometheus", "uid": "${prometheus}"}
|
||||
SEL = '{container="iPX"}'
|
||||
# ipx logs one JSON object a line (IPX_LOG_FORMAT=json). Each scan and download event carries its
|
||||
# fields (ev, feed, new, bytes, msg, error.type, ...); 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.
|
||||
EV = SEL + ' |= "\\"ev\\":\\"" | json | __error__="" | ev != ""'
|
||||
# Each request ipx answers, the event stream aside, as a histogram (#364): its line in the log is
|
||||
|
||||
@@ -7,7 +7,7 @@ cd "$(dirname "$0")/.."
|
||||
|
||||
case "${1:-}" in
|
||||
ipx) name=ipx; ver=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ipx/Cargo.toml | head -1)
|
||||
tag=v$ver; log=crates/ipx/CHANGELOG.md; from=/usr/local/bin/ipx ;;
|
||||
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)
|
||||
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')
|
||||
@@ -31,7 +31,7 @@ if [ "$name" = ipx-web ]; then
|
||||
arch=any
|
||||
else
|
||||
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)
|
||||
fi
|
||||
cp LICENSE README.md "$dir/"; cp "$log" "$dir/CHANGELOG.md"
|
||||
|
||||
@@ -338,7 +338,7 @@ export const ipx: Api = {
|
||||
{
|
||||
method: 'GET', path: '/api/status', slug: 'get-status',
|
||||
summary: 'How many feeds, files waiting and files downloaded, and the version.',
|
||||
doc: '<p>The numbers <code>ipx status</code> prints, for a dashboard.</p>',
|
||||
doc: '<p>The numbers <code>ipxd status</code> prints, for a dashboard.</p>',
|
||||
returns: 'Status', example: ipxCurl('GET', '/api/status'),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
<div class="scroll"><table>
|
||||
<thead><tr><th>Command</th><th>What it does</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>ipx list</code></td><td>Every podcast and its state</td></tr>
|
||||
<tr><td><code>ipx status</code></td><td>How many podcasts, how many files waiting and downloaded</td></tr>
|
||||
<tr><td><code>ipx fetch [id] [--force]</code></td><td>Check everything that is due, or one podcast; <code>--force</code> checks it even if it is not due</td></tr>
|
||||
<tr><td><code>ipx add <url></code>, <code>ipx rm <id></code></td><td>Add a podcast, or take it out</td></tr>
|
||||
<tr><td><code>ipx import <file></code>, <code>ipx export <file></code></td><td>OPML in or out</td></tr>
|
||||
<tr><td><code>ipx reap [--dry-run]</code></td><td>Clear old files now, to stay within the space you set</td></tr>
|
||||
<tr><td><code>ipx user add|list|passwd|rm</code></td><td>Accounts</td></tr>
|
||||
<tr><td><code>ipxd list</code></td><td>Every podcast and its state</td></tr>
|
||||
<tr><td><code>ipxd status</code></td><td>How many podcasts, how many files waiting and downloaded</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>ipxd add <url></code>, <code>ipxd rm <id></code></td><td>Add a podcast, or take it out</td></tr>
|
||||
<tr><td><code>ipxd import <file></code>, <code>ipxd export <file></code></td><td>OPML in or out</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>ipxd user add|list|passwd|rm</code></td><td>Accounts</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<h2>The control socket</h2>
|
||||
|
||||
@@ -35,12 +35,12 @@ docker build -f Dockerfile.web -t ipx-web .</code></pre>
|
||||
- ipx</code></pre>
|
||||
<pre><code>docker compose up -d</code></pre>
|
||||
<p>ipx has no port of its own: everything reaches it through ipx-web, on port 8099. Open <code>http://<your server>:8099</code> and sign in as <strong>admin</strong> with the password <strong>ipodderx</strong>, then change it:</p>
|
||||
<pre><code>echo -n 'a good password' | docker exec -i ipx ipx user passwd admin</code></pre>
|
||||
<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>
|
||||
|
||||
<h2>Adding people</h2>
|
||||
<p>An admin adds people in Admin, Accounts, or from the command line:</p>
|
||||
<pre><code>echo -n 'their password' | docker exec -i ipx ipx user add sam</code></pre>
|
||||
<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>
|
||||
|
||||
<h2>Settings</h2>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// It builds a release binary (debug Argon2 alone takes a second a sign-in, which would measure
|
||||
// the build, not ipx), serves generated feeds from this process, starts a daemon on its own
|
||||
// config and data under /tmp/ipx-load, wiped first, seeds listeners, then runs each k6 script.
|
||||
// While each runs, `ipx status` 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
|
||||
// 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 ITEMS = 20;
|
||||
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 POSTGRES = process.argv.includes('--postgres');
|
||||
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 (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 });
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
/// the daemon stalls writing its next line, which is the test measuring itself.
|
||||
function watchHealth() {
|
||||
@@ -269,7 +269,7 @@ function k6(script: string) {
|
||||
const stop = watchHealth();
|
||||
const code = await k6(script);
|
||||
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 (health.failed || health.slowest >= 5000) { failed++; console.log(`${script}: the healthcheck would have failed`); }
|
||||
}
|
||||
|
||||
@@ -1028,7 +1028,7 @@ test('an export from one account imports into another', async ({ page, browser }
|
||||
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
|
||||
// reads config.toml once at start, so it would not see what the CLI added anyway.
|
||||
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 });
|
||||
});
|
||||
|
||||
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 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).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0"?>
|
||||
<rss version="2.0"><channel><title>Command Line Show</title><link>http://127.0.0.1:8792/</link>
|
||||
<description>Added with ipx add while the daemon runs.</description>
|
||||
<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>
|
||||
</channel></rss>
|
||||
|
||||
@@ -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
|
||||
/// tests' own `ipx` commands included; without it, the debug build it makes itself.
|
||||
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';
|
||||
|
||||
export { prepare, root, TOKEN, DIRECTORY, DIRECTORY_TOKEN, API_COVERAGE, IPX_BIN, DIRECTORY_BIN };
|
||||
|
||||
Reference in New Issue
Block a user