Phase 2: web front end
axum served from inside the daemon so it reads SQLite and the event bus directly: browse feeds, read show notes, play with seeking, download and delete files, mark read/flag, and edit feed settings. Config is now hot-reloadable (Ctx.cfg behind RwLock<Arc<Config>>), so UI edits apply without a daemon restart. Access is a shared token minted from /dev/urandom, carried in a cookie because an <audio> element cannot send headers. Show notes are untrusted feed HTML and are sanitized with ammonia server-side. read/flagged finally have a writer, which retention has needed since it started ordering by them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
This commit is contained in:
211
Cargo.lock
generated
211
Cargo.lock
generated
@@ -23,6 +23,18 @@ version = "0.2.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ammonia"
|
||||||
|
version = "4.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
|
||||||
|
dependencies = [
|
||||||
|
"cssparser",
|
||||||
|
"html5ever",
|
||||||
|
"maplit",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -222,6 +234,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"axum-core",
|
"axum-core",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"form_urlencoded",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
@@ -235,6 +248,9 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
"serde_json",
|
||||||
|
"serde_path_to_error",
|
||||||
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
@@ -590,6 +606,17 @@ version = "0.8.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cssparser"
|
||||||
|
version = "0.37.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
|
||||||
|
dependencies = [
|
||||||
|
"dtoa-short",
|
||||||
|
"itoa",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.20.11"
|
version = "0.20.11"
|
||||||
@@ -813,6 +840,21 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dtoa"
|
||||||
|
version = "1.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dtoa-short"
|
||||||
|
version = "0.3.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
|
||||||
|
dependencies = [
|
||||||
|
"dtoa",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dunce"
|
name = "dunce"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -1206,6 +1248,16 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "html5ever"
|
||||||
|
version = "0.39.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"markup5ever",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -1239,6 +1291,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-range-header"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
@@ -1498,8 +1556,10 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
|||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"atom_syndication",
|
"atom_syndication",
|
||||||
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dirs",
|
"dirs",
|
||||||
@@ -1514,7 +1574,10 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
"toml",
|
"toml",
|
||||||
|
"tower",
|
||||||
|
"tower-http 0.7.1",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"url",
|
"url",
|
||||||
@@ -2045,6 +2108,23 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "maplit"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markup5ever"
|
||||||
|
version = "0.39.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"tendril",
|
||||||
|
"web_atoms",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2161,6 +2241,12 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "new_debug_unreachable"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.30.1"
|
version = "0.30.1"
|
||||||
@@ -2320,6 +2406,45 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||||
|
dependencies = [
|
||||||
|
"phf_shared",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_codegen"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator",
|
||||||
|
"phf_shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_generator"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"phf_shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -2371,6 +2496,12 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "precomputed-hash"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.107"
|
version = "1.0.107"
|
||||||
@@ -2666,7 +2797,7 @@ dependencies = [
|
|||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http 0.6.11",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -3092,6 +3223,12 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "size_format"
|
name = "size_format"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -3151,6 +3288,30 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "string_cache"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
|
||||||
|
dependencies = [
|
||||||
|
"new_debug_unreachable",
|
||||||
|
"parking_lot",
|
||||||
|
"phf_shared",
|
||||||
|
"precomputed-hash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "string_cache_codegen"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator",
|
||||||
|
"phf_shared",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -3243,6 +3404,15 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tendril"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
|
||||||
|
dependencies = [
|
||||||
|
"new_debug_unreachable",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@@ -3474,6 +3644,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3499,6 +3670,31 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-http"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"http-range-header",
|
||||||
|
"httpdate",
|
||||||
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-layer"
|
name = "tower-layer"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@@ -3517,6 +3713,7 @@ version = "0.1.44"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-attributes",
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
@@ -3790,6 +3987,18 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web_atoms"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
|
||||||
|
dependencies = [
|
||||||
|
"phf",
|
||||||
|
"phf_codegen",
|
||||||
|
"string_cache",
|
||||||
|
"string_cache_codegen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-root-certs"
|
name = "webpki-root-certs"
|
||||||
version = "1.0.9"
|
version = "1.0.9"
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
ammonia = "4.1.4"
|
||||||
anyhow = "1.0.104"
|
anyhow = "1.0.104"
|
||||||
atom_syndication = "0.12.10"
|
atom_syndication = "0.12.10"
|
||||||
|
axum = "0.8.9"
|
||||||
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
||||||
clap = { version = "4.6.6", features = ["derive"] }
|
clap = { version = "4.6.6", features = ["derive"] }
|
||||||
dirs = "7.0.0"
|
dirs = "7.0.0"
|
||||||
@@ -20,7 +22,10 @@ rusqlite = { version = "0.40.2", features = ["bundled"] }
|
|||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.151"
|
serde_json = "1.0.151"
|
||||||
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }
|
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }
|
||||||
|
tokio-stream = { version = "0.1.19", features = ["sync"] }
|
||||||
toml = "1.1.5"
|
toml = "1.1.5"
|
||||||
|
tower = { version = "0.5.3", features = ["util"] }
|
||||||
|
tower-http = { version = "0.7.1", features = ["fs"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
|
|||||||
75
PROGRESS.md
75
PROGRESS.md
@@ -17,6 +17,32 @@ The full design and step list live in the plan file at
|
|||||||
see the step 7 entry)
|
see the step 7 entry)
|
||||||
- [x] **8. OPML + polish** — import/export, add/rm/status, tracing setup, systemd units, README.
|
- [x] **8. OPML + polish** — import/export, add/rm/status, tracing setup, systemd units, README.
|
||||||
|
|
||||||
|
### Phase 2 — web front end
|
||||||
|
|
||||||
|
Decided with Ray: axum serving plain HTML/JS (no WASM toolchain), running **inside the daemon**
|
||||||
|
process so it reads SQLite and the event bus directly, LAN-bindable with a shared token.
|
||||||
|
|
||||||
|
- [x] **9. Config hot-reload + web skeleton.** `Ctx.cfg` becomes `RwLock<Arc<Config>>` so the UI can
|
||||||
|
edit feeds without a daemon restart. `[web]` config section (enabled/bind/token, token
|
||||||
|
auto-generated and saved on first run). axum server started by `ipx daemon`, token checked by
|
||||||
|
middleware, `?token=` sets a cookie so `<audio>` requests authenticate too.
|
||||||
|
*Done when:* `ipx daemon` serves a page on the configured bind, and a wrong token gets 401.
|
||||||
|
- [x] **10. Browsing.** `/api/feeds`, `/api/feeds/:id/entries`, entry detail. Descriptions are
|
||||||
|
untrusted feed HTML — sanitized with `ammonia` before they reach the page.
|
||||||
|
*Done when:* the Glass Cannon feed's 131 entries browse and read correctly.
|
||||||
|
- [x] **11. Media actions.** Range-request audio streaming (`tower-http` ServeFile) so seeking
|
||||||
|
works, download-on-demand for a pending enclosure, delete a file, mark read/flagged.
|
||||||
|
*Done when:* an episode plays and seeks in a browser, and delete reaps the row.
|
||||||
|
- [x] **12. Feed configuration.** Add/remove feeds and edit folder, keywords, allow_explicit,
|
||||||
|
auto_download, max_new_per_check from the UI, written back to config.toml and hot-reloaded.
|
||||||
|
*Done when:* flipping allow_explicit in the UI takes effect on the next scan with no restart.
|
||||||
|
- [x] **13. Live progress + polish.** SSE from the existing broadcast bus so downloads show live.
|
||||||
|
README section, screenshot-free usage notes.
|
||||||
|
*Done when:* starting a fetch from the UI shows progress advancing without a reload.
|
||||||
|
|
||||||
|
Note: `read`/`flagged` finally get a writer here. Retention orders by them (see the step 5 entry),
|
||||||
|
and until now nothing set them.
|
||||||
|
|
||||||
## Smoke tests
|
## Smoke tests
|
||||||
|
|
||||||
1. `ipx add <feed>` + `ipx fetch` → file in `download_dir/<Show>/`, row in `enclosures`.
|
1. `ipx add <feed>` + `ipx fetch` → file in `download_dir/<Show>/`, row in `enclosures`.
|
||||||
@@ -30,6 +56,55 @@ The full design and step list live in the plan file at
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-09-10 — Phase 2: web front end (steps 9-13)
|
||||||
|
|
||||||
|
axum in the daemon process, plain HTML/JS in `web/index.html` (embedded with `include_str!`), no
|
||||||
|
WASM toolchain. One binary still.
|
||||||
|
|
||||||
|
**Config hot-reload.** `Ctx.cfg` is now `RwLock<Arc<Config>>`; `ctx.cfg()` hands out a snapshot, so
|
||||||
|
no guard is ever held across an await. The UI rewrites config.toml and calls `reload_cfg`, and a
|
||||||
|
running daemon picks the change up on its next scan. The CLI mutation commands (`add`/`rm`/`import`)
|
||||||
|
now clone a snapshot, edit, and save.
|
||||||
|
|
||||||
|
**Auth.** `[web] enabled/bind/token`; the token is minted from `/dev/urandom` on first run, written
|
||||||
|
back to config.toml, and the URL printed. `?token=` sets a year-long cookie — it has to be a cookie
|
||||||
|
because an `<audio>` element cannot send a header. Comparison is constant-time. An empty token makes
|
||||||
|
the server refuse to serve rather than serve open.
|
||||||
|
|
||||||
|
**Endpoints.** `/api/feeds` (GET/POST), `/api/feeds/{id}` (PATCH/DELETE),
|
||||||
|
`/api/feeds/{id}/entries`, `/api/entries/{feed}/{guid}/flags`, `/api/enclosures/{id}/download`,
|
||||||
|
`/api/enclosures/{id}` (DELETE), `/api/fetch`, `/api/events` (SSE off the existing broadcast bus),
|
||||||
|
`/media/{id}` (tower-http `ServeFile`, so Range works).
|
||||||
|
|
||||||
|
**`read`/`flagged` finally have a writer** — the UI sets them, and playing an episode marks it read.
|
||||||
|
Retention has ordered by these since step 5 with nothing to set them.
|
||||||
|
|
||||||
|
**Download-on-demand needed no new machinery**: requeue the row to `pending` and kick a scan, since
|
||||||
|
the queue is the table. That also un-skips an enclosure a filter rejected under older settings.
|
||||||
|
|
||||||
|
Verified against the live Glass Cannon feed, daemon on 127.0.0.1:8749:
|
||||||
|
|
||||||
|
- auth: no token 401, wrong token 401, right token 200 + cookie, API then works on the cookie alone;
|
||||||
|
`/media/1` unauthenticated is 401.
|
||||||
|
- browsing: 131 entries page with enclosures attached.
|
||||||
|
- **XSS**: injected `<script>alert(1)</script><img src=x onerror=alert(2)>` into a stored
|
||||||
|
description; it reaches the page as `<p>hi</p><img src="x">` — script tag and handler both gone.
|
||||||
|
- media: 200 with `accept-ranges: bytes`; `Range: bytes=1000000-1000999` returns 206 with the right
|
||||||
|
`content-range`, so seeking works.
|
||||||
|
- config: PATCH -> 204, written to config.toml, and the running daemon reports the new values with
|
||||||
|
no restart.
|
||||||
|
- SSE: 105 events for one download (101 progress), then `download_done`/`feed_done`/`scan_done`.
|
||||||
|
- flags: 204, unread count dropped 131 -> 130. Delete: file gone, row `reaped`/`path=NULL`.
|
||||||
|
|
||||||
|
`cargo test` 30/30.
|
||||||
|
|
||||||
|
**Caveats.** It is plain HTTP — on a LAN bind the token crosses the network in the clear, and a feed
|
||||||
|
URL may itself carry a credential (Patreon's does), which the `/api/feeds` response includes. A
|
||||||
|
reverse proxy with TLS is the answer if that matters. There is no per-user anything; the token is
|
||||||
|
all-or-nothing access.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-09-10 — Tested against a real feed (Patreon / Glass Cannon)
|
## 2026-09-10 — Tested against a real feed (Patreon / Glass Cannon)
|
||||||
|
|
||||||
First run against a live subscriber feed: 131 items, 6.17 GB total, all `audio/mpeg`, no `<ttl>`.
|
First run against a live subscriber feed: 131 items, 6.17 GB total, all `audio/mpeg`, no `<ttl>`.
|
||||||
|
|||||||
33
README.md
33
README.md
@@ -101,6 +101,39 @@ Events: `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `downl
|
|||||||
Progress is throttled to whole percents. The stream is a broadcast, so a client attached to a busy
|
Progress is throttled to whole percents. The stream is a broadcast, so a client attached to a busy
|
||||||
daemon also sees that daemon's other work.
|
daemon also sees that daemon's other work.
|
||||||
|
|
||||||
|
## Web UI
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[web]
|
||||||
|
enabled = true
|
||||||
|
bind = "0.0.0.0:8080" # 127.0.0.1:8080 by default
|
||||||
|
token = "" # generated and written back on first run
|
||||||
|
```
|
||||||
|
|
||||||
|
`ipx daemon` then serves it in the same process (`ipx daemon --web ADDR` overrides the bind for one
|
||||||
|
run). On first start it mints a token, saves it to config.toml, and prints the URL to open:
|
||||||
|
|
||||||
|
```
|
||||||
|
web ui token generated. Open:
|
||||||
|
http://0.0.0.0:8080/?token=1f4c…
|
||||||
|
```
|
||||||
|
|
||||||
|
`?token=` sets a year-long cookie, so you only paste it once per browser. Everything is behind that
|
||||||
|
token, including `/media/...` — a cookie rather than a header precisely because an `<audio>` element
|
||||||
|
cannot send headers.
|
||||||
|
|
||||||
|
Browse feeds, read show notes, play episodes in the browser (Range requests are served, so seeking
|
||||||
|
works), download or delete individual files, mark episodes read or flag them to keep, and edit a
|
||||||
|
feed's folder/keywords/explicit/auto-download/limit settings. Config edits are written to
|
||||||
|
config.toml and hot-reloaded — no daemon restart.
|
||||||
|
|
||||||
|
Show notes are feed-supplied HTML from an untrusted source; they are sanitized with `ammonia`
|
||||||
|
server-side before they reach the page.
|
||||||
|
|
||||||
|
**It is plain HTTP.** On a LAN bind, the token and everything else crosses the network in the
|
||||||
|
clear — and a feed URL can itself contain a credential (Patreon's, for one, carries an auth token).
|
||||||
|
Put it behind a reverse proxy with TLS if that matters to you.
|
||||||
|
|
||||||
## Running it as a service
|
## Running it as a service
|
||||||
|
|
||||||
`contrib/` has a systemd user unit for the daemon, and a timer plus one-shot service if you would
|
`contrib/` has a systemd user unit for the daemon, and a timer plus one-shot service if you would
|
||||||
|
|||||||
@@ -5,18 +5,20 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub general: General,
|
pub general: General,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub torrent: Torrent,
|
pub torrent: Torrent,
|
||||||
|
#[serde(default)]
|
||||||
|
pub web: Web,
|
||||||
/// Keyed by feed id: the TOML table name, which replaces the old genHash(feedURL).
|
/// Keyed by feed id: the TOML table name, which replaces the old genHash(feedURL).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub feeds: BTreeMap<String, Feed>,
|
pub feeds: BTreeMap<String, Feed>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct General {
|
pub struct General {
|
||||||
pub download_dir: PathBuf,
|
pub download_dir: PathBuf,
|
||||||
@@ -39,7 +41,7 @@ pub enum Organize {
|
|||||||
Date,
|
Date,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Torrent {
|
pub struct Torrent {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
@@ -51,6 +53,32 @@ pub struct Torrent {
|
|||||||
pub stall_mins: u64,
|
pub stall_mins: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Web {
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Use 0.0.0.0 to reach it from the LAN. Anything but loopback needs the token.
|
||||||
|
pub bind: String,
|
||||||
|
/// Shared secret. Generated and written back on first run when left empty.
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Web {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
bind: "127.0.0.1:8080".into(),
|
||||||
|
token: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Web {
|
||||||
|
pub fn binds_publicly(&self) -> bool {
|
||||||
|
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct Feed {
|
pub struct Feed {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
|||||||
155
src/db.rs
155
src/db.rs
@@ -386,6 +386,15 @@ impl Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
|
pub fn unread_count(&self, feed_id: &str) -> Result<i64> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
Ok(conn.query_row(
|
||||||
|
"SELECT count(*) FROM entries WHERE feed_id = ?1 AND read = 0",
|
||||||
|
[feed_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
/// (pending, downloaded) across all feeds, for the status command.
|
/// (pending, downloaded) across all feeds, for the status command.
|
||||||
pub fn counts(&self) -> Result<(i64, i64)> {
|
pub fn counts(&self) -> Result<(i64, i64)> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
@@ -396,6 +405,152 @@ impl Db {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// An entry plus its enclosures, for the web UI.
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
pub struct EntryRow {
|
||||||
|
pub guid: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub link: Option<String>,
|
||||||
|
pub published: Option<i64>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub read: bool,
|
||||||
|
pub flagged: bool,
|
||||||
|
pub enclosures: Vec<EncRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct EncRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub feed_id: String,
|
||||||
|
pub guid: String,
|
||||||
|
pub url: String,
|
||||||
|
pub mime: Option<String>,
|
||||||
|
pub length: Option<i64>,
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub state: String,
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Db {
|
||||||
|
/// One page of a feed's entries, newest first, each with its enclosures attached.
|
||||||
|
pub fn entries(&self, feed_id: &str, offset: i64, limit: i64) -> Result<Vec<EntryRow>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT guid, title, link, published, description, read, flagged
|
||||||
|
FROM entries WHERE feed_id = ?1
|
||||||
|
ORDER BY coalesce(published, first_seen) DESC, rowid DESC
|
||||||
|
LIMIT ?3 OFFSET ?2",
|
||||||
|
)?;
|
||||||
|
let mut rows: Vec<EntryRow> = stmt
|
||||||
|
.query_map(rusqlite::params![feed_id, offset, limit], |r| {
|
||||||
|
Ok(EntryRow {
|
||||||
|
guid: r.get(0)?,
|
||||||
|
title: r.get(1)?,
|
||||||
|
link: r.get(2)?,
|
||||||
|
published: r.get(3)?,
|
||||||
|
description: r.get(4)?,
|
||||||
|
read: r.get::<_, i64>(5)? != 0,
|
||||||
|
flagged: r.get::<_, i64>(6)? != 0,
|
||||||
|
enclosures: vec![],
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Ok(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the guids on this page, so a feed with thousands of entries stays cheap.
|
||||||
|
let placeholders = std::iter::repeat_n("?", rows.len()).collect::<Vec<_>>().join(",");
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT id, feed_id, guid, url, mime, length, path, state, last_error
|
||||||
|
FROM enclosures WHERE feed_id = ? AND guid IN ({placeholders}) ORDER BY id"
|
||||||
|
);
|
||||||
|
let mut params: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(rows.len() + 1);
|
||||||
|
params.push(&feed_id);
|
||||||
|
for row in &rows {
|
||||||
|
params.push(&row.guid);
|
||||||
|
}
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
let encs = stmt
|
||||||
|
.query_map(params.as_slice(), |r| {
|
||||||
|
Ok(EncRow {
|
||||||
|
id: r.get(0)?,
|
||||||
|
feed_id: r.get(1)?,
|
||||||
|
guid: r.get(2)?,
|
||||||
|
url: r.get(3)?,
|
||||||
|
mime: r.get(4)?,
|
||||||
|
length: r.get(5)?,
|
||||||
|
path: r.get(6)?,
|
||||||
|
state: r.get(7)?,
|
||||||
|
last_error: r.get(8)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
for enc in encs {
|
||||||
|
if let Some(row) = rows.iter_mut().find(|r| r.guid == enc.guid) {
|
||||||
|
row.enclosures.push(enc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enclosure(&self, id: i64) -> Result<Option<EncRow>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
Ok(conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id, feed_id, guid, url, mime, length, path, state, last_error
|
||||||
|
FROM enclosures WHERE id = ?1",
|
||||||
|
[id],
|
||||||
|
|r| {
|
||||||
|
Ok(EncRow {
|
||||||
|
id: r.get(0)?,
|
||||||
|
feed_id: r.get(1)?,
|
||||||
|
guid: r.get(2)?,
|
||||||
|
url: r.get(3)?,
|
||||||
|
mime: r.get(4)?,
|
||||||
|
length: r.get(5)?,
|
||||||
|
path: r.get(6)?,
|
||||||
|
state: r.get(7)?,
|
||||||
|
last_error: r.get(8)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `read` and `flagged` finally get a writer: retention orders by them.
|
||||||
|
pub fn set_entry_flag(&self, feed_id: &str, guid: &str, field: EntryFlag, on: bool) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let sql = match field {
|
||||||
|
EntryFlag::Read => "UPDATE entries SET read = ?3 WHERE feed_id = ?1 AND guid = ?2",
|
||||||
|
EntryFlag::Flagged => "UPDATE entries SET flagged = ?3 WHERE feed_id = ?1 AND guid = ?2",
|
||||||
|
};
|
||||||
|
conn.execute(sql, rusqlite::params![feed_id, guid, on as i64])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts an enclosure back in the queue so the next scan picks it up. This is how a
|
||||||
|
/// `skipped` verdict (from a filter that has since been changed) gets revisited.
|
||||||
|
pub fn requeue(&self, id: i64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE enclosures SET state = 'pending', last_error = NULL
|
||||||
|
WHERE id = ?1 AND path IS NULL",
|
||||||
|
[id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum EntryFlag {
|
||||||
|
Read,
|
||||||
|
Flagged,
|
||||||
|
}
|
||||||
|
|
||||||
/// Unix seconds. Everything time-shaped in the DB is stored this way.
|
/// Unix seconds. Everything time-shaped in the DB is stored this way.
|
||||||
pub fn now() -> i64 {
|
pub fn now() -> i64 {
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
|
|||||||
177
src/main.rs
177
src/main.rs
@@ -5,11 +5,13 @@ mod feed;
|
|||||||
mod ipc;
|
mod ipc;
|
||||||
mod retention;
|
mod retention;
|
||||||
mod torrent;
|
mod torrent;
|
||||||
|
mod web;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use ipc::{Command as Cmd, Emitter, Event};
|
use ipc::{Command as Cmd, Emitter, Event};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@@ -64,24 +66,44 @@ enum Command {
|
|||||||
/// Write subscriptions out as OPML
|
/// Write subscriptions out as OPML
|
||||||
Export { file: PathBuf },
|
Export { file: PathBuf },
|
||||||
/// Run the scheduler and serve the control socket
|
/// Run the scheduler and serve the control socket
|
||||||
Daemon,
|
Daemon {
|
||||||
|
/// Serve the web UI on this address, overriding [web] in the config
|
||||||
|
#[arg(long, value_name = "ADDR")]
|
||||||
|
web: Option<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything a command needs. One per process.
|
/// Everything a command needs. One per process.
|
||||||
struct Ctx {
|
pub struct Ctx {
|
||||||
cfg: config::Config,
|
/// Swapped wholesale when the web UI rewrites config.toml, so a running daemon picks
|
||||||
db: db::Db,
|
/// up feed changes without a restart. Callers take a snapshot; no guard is ever held
|
||||||
client: reqwest::Client,
|
/// across an await.
|
||||||
out: Emitter,
|
pub cfg: std::sync::RwLock<std::sync::Arc<config::Config>>,
|
||||||
|
pub db: db::Db,
|
||||||
|
pub client: reqwest::Client,
|
||||||
|
pub out: Emitter,
|
||||||
/// Started on first use: a BitTorrent session binds ports and starts a DHT, which is
|
/// Started on first use: a BitTorrent session binds ports and starts a DHT, which is
|
||||||
/// rude to do for a config that has never seen a torrent.
|
/// rude to do for a config that has never seen a torrent.
|
||||||
torrents: tokio::sync::OnceCell<torrent::Torrents>,
|
pub torrents: tokio::sync::OnceCell<torrent::Torrents>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ctx {
|
impl Ctx {
|
||||||
|
pub fn cfg(&self) -> std::sync::Arc<config::Config> {
|
||||||
|
self.cfg.read().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-reads config.toml into the live snapshot.
|
||||||
|
pub fn reload_cfg(&self, path: &std::path::Path) -> Result<()> {
|
||||||
|
let fresh = config::Config::load(path)?;
|
||||||
|
*self.cfg.write().unwrap() = std::sync::Arc::new(fresh);
|
||||||
|
tracing::info!("config reloaded");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn torrents(&self) -> Result<&torrent::Torrents> {
|
async fn torrents(&self) -> Result<&torrent::Torrents> {
|
||||||
|
let cfg = self.cfg();
|
||||||
self.torrents
|
self.torrents
|
||||||
.get_or_try_init(|| torrent::Torrents::new(&self.cfg))
|
.get_or_try_init(|| torrent::Torrents::new(&cfg))
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,7 +131,7 @@ async fn main() -> Result<()> {
|
|||||||
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
|
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
|
||||||
Command::Status => Some(Cmd::Status),
|
Command::Status => Some(Cmd::Status),
|
||||||
Command::List
|
Command::List
|
||||||
| Command::Daemon
|
| Command::Daemon { .. }
|
||||||
| Command::Add { .. }
|
| Command::Add { .. }
|
||||||
| Command::Rm { .. }
|
| Command::Rm { .. }
|
||||||
| Command::Import { .. }
|
| Command::Import { .. }
|
||||||
@@ -123,7 +145,7 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ctx = Ctx {
|
let ctx = Ctx {
|
||||||
cfg,
|
cfg: std::sync::RwLock::new(std::sync::Arc::new(cfg)),
|
||||||
db,
|
db,
|
||||||
client: reqwest::Client::builder()
|
client: reqwest::Client::builder()
|
||||||
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
||||||
@@ -134,7 +156,7 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::List => list(&ctx, &config_path),
|
Command::List => list(&ctx, &config_path),
|
||||||
Command::Daemon => daemon(ctx).await,
|
Command::Daemon { web } => daemon(ctx, config_path, web).await,
|
||||||
Command::Add { url, folder, keywords } => {
|
Command::Add { url, folder, keywords } => {
|
||||||
add(ctx, &config_path, &url, folder, keywords).await
|
add(ctx, &config_path, &url, folder, keywords).await
|
||||||
}
|
}
|
||||||
@@ -155,28 +177,29 @@ async fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> {
|
|||||||
Cmd::Reap { dry_run } => reap(ctx, dry_run, true),
|
Cmd::Reap { dry_run } => reap(ctx, dry_run, true),
|
||||||
Cmd::Status => {
|
Cmd::Status => {
|
||||||
let (pending, downloaded) = ctx.db.counts()?;
|
let (pending, downloaded) = ctx.db.counts()?;
|
||||||
ctx.out.emit(Event::Status { feeds: ctx.cfg.feeds.len(), pending, downloaded });
|
ctx.out.emit(Event::Status { feeds: ctx.cfg().feeds.len(), pending, downloaded });
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn daemon(ctx: Ctx) -> Result<()> {
|
async fn daemon(ctx: Ctx, config_path: PathBuf, web_addr: Option<String>) -> Result<()> {
|
||||||
let socket = ctx.cfg.general.socket.clone();
|
let socket = ctx.cfg().general.socket.clone();
|
||||||
if ipc::daemon_is_live(&socket).await {
|
if ipc::daemon_is_live(&socket).await {
|
||||||
anyhow::bail!("a daemon is already listening on {}", socket.display());
|
anyhow::bail!("a daemon is already listening on {}", socket.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
let (events, _) = broadcast::channel(1024);
|
let (events, _) = broadcast::channel(1024);
|
||||||
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(64);
|
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(64);
|
||||||
let ctx = Ctx { out: Emitter::socket(events.clone(), false), ..ctx };
|
let ctx = Arc::new(Ctx { out: Emitter::socket(events.clone(), false), ..ctx });
|
||||||
|
|
||||||
let server = tokio::spawn(ipc::serve(socket.clone(), events, tx_cmd));
|
let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).await?;
|
||||||
|
let server = tokio::spawn(ipc::serve(socket.clone(), events.clone(), tx_cmd));
|
||||||
|
|
||||||
// One command at a time: the queue is what keeps two scans from overlapping.
|
// One command at a time: the queue is what keeps two scans from overlapping.
|
||||||
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
|
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
tracing::info!(feeds = ctx.cfg.feeds.len(), "daemon started");
|
tracing::info!(feeds = ctx.cfg().feeds.len(), "daemon started");
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -197,11 +220,61 @@ async fn daemon(ctx: Ctx) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server.abort();
|
server.abort();
|
||||||
|
if let Some(w) = web {
|
||||||
|
w.abort();
|
||||||
|
}
|
||||||
let _ = std::fs::remove_file(&socket);
|
let _ = std::fs::remove_file(&socket);
|
||||||
tracing::info!("daemon stopped");
|
tracing::info!("daemon stopped");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Starts the web UI when it is switched on, minting and saving a token if there is none.
|
||||||
|
async fn start_web(
|
||||||
|
ctx: &Arc<Ctx>,
|
||||||
|
config_path: &std::path::Path,
|
||||||
|
web_addr: Option<String>,
|
||||||
|
cmds: &mpsc::Sender<Cmd>,
|
||||||
|
events: &broadcast::Sender<Event>,
|
||||||
|
) -> Result<Option<tokio::task::JoinHandle<()>>> {
|
||||||
|
let cfg = ctx.cfg();
|
||||||
|
let enabled = cfg.web.enabled || web_addr.is_some();
|
||||||
|
if !enabled {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let bind = web_addr.unwrap_or_else(|| cfg.web.bind.clone());
|
||||||
|
|
||||||
|
if cfg.web.token.is_empty() {
|
||||||
|
let mut fresh = (*cfg).clone();
|
||||||
|
fresh.web.enabled = true;
|
||||||
|
fresh.web.bind = bind.clone();
|
||||||
|
fresh.web.token = web::generate_token();
|
||||||
|
fresh.save(config_path)?;
|
||||||
|
ctx.reload_cfg(config_path)?;
|
||||||
|
println!("web ui token generated. Open:\n http://{bind}/?token={}", fresh.web.token);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"web ui at http://{bind}/?token={}",
|
||||||
|
ctx.cfg().web.token
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.cfg().web.binds_publicly() {
|
||||||
|
tracing::warn!(bind, "web ui is reachable off this machine; the token is all that guards it");
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = web::WebState {
|
||||||
|
ctx: ctx.clone(),
|
||||||
|
config_path: config_path.to_path_buf(),
|
||||||
|
cmds: cmds.clone(),
|
||||||
|
events: events.clone(),
|
||||||
|
};
|
||||||
|
Ok(Some(tokio::spawn(async move {
|
||||||
|
if let Err(e) = web::serve(state, &bind).await {
|
||||||
|
tracing::error!(error = ?e, "web ui stopped");
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
async fn shutdown() {
|
async fn shutdown() {
|
||||||
use tokio::signal::unix::{SignalKind, signal};
|
use tokio::signal::unix::{SignalKind, signal};
|
||||||
let mut term = match signal(SignalKind::terminate()) {
|
let mut term = match signal(SignalKind::terminate()) {
|
||||||
@@ -216,25 +289,27 @@ async fn shutdown() {
|
|||||||
|
|
||||||
/// Subscribes to one feed, naming it from its own title.
|
/// Subscribes to one feed, naming it from its own title.
|
||||||
async fn add(
|
async fn add(
|
||||||
mut ctx: Ctx,
|
ctx: Ctx,
|
||||||
config_path: &std::path::Path,
|
config_path: &std::path::Path,
|
||||||
url: &str,
|
url: &str,
|
||||||
folder: Option<String>,
|
folder: Option<String>,
|
||||||
keywords: Vec<String>,
|
keywords: Vec<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if let Some((id, _)) = ctx.cfg.feeds.iter().find(|(_, f)| f.url == url) {
|
let mut cfg = (*ctx.cfg()).clone();
|
||||||
|
if let Some((id, _)) = cfg.feeds.iter().find(|(_, f)| f.url == url) {
|
||||||
anyhow::bail!("already subscribed as {id:?}");
|
anyhow::bail!("already subscribed as {id:?}");
|
||||||
}
|
}
|
||||||
let id = add_one(&mut ctx, url, folder, keywords).await?;
|
let id = add_one(&ctx, &mut cfg, url, folder, keywords).await?;
|
||||||
ctx.cfg.save(config_path)?;
|
cfg.save(config_path)?;
|
||||||
println!("added {id}");
|
println!("added {id}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the new feed id. The title needs a fetch, so a feed that cannot be reached is
|
/// Returns the new feed id. The title needs a fetch, so a feed that cannot be reached is
|
||||||
/// still added -- under a slug derived from its URL -- rather than refused.
|
/// still added -- under a slug derived from its URL -- rather than refused.
|
||||||
async fn add_one(
|
pub async fn add_one(
|
||||||
ctx: &mut Ctx,
|
ctx: &Ctx,
|
||||||
|
cfg: &mut config::Config,
|
||||||
url: &str,
|
url: &str,
|
||||||
folder: Option<String>,
|
folder: Option<String>,
|
||||||
keywords: Vec<String>,
|
keywords: Vec<String>,
|
||||||
@@ -262,8 +337,8 @@ async fn add_one(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let id = config::unique_slug(&title, &ctx.cfg.feeds);
|
let id = config::unique_slug(&title, &cfg.feeds);
|
||||||
ctx.cfg.feeds.insert(id.clone(), probe);
|
cfg.feeds.insert(id.clone(), probe);
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,17 +350,19 @@ fn url_stem(url: &str) -> String {
|
|||||||
.unwrap_or_else(|| url.to_owned())
|
.unwrap_or_else(|| url.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rm(mut ctx: Ctx, config_path: &std::path::Path, feed: &str) -> Result<()> {
|
fn rm(ctx: Ctx, config_path: &std::path::Path, feed: &str) -> Result<()> {
|
||||||
if ctx.cfg.feeds.remove(feed).is_none() {
|
let mut cfg = (*ctx.cfg()).clone();
|
||||||
|
if cfg.feeds.remove(feed).is_none() {
|
||||||
anyhow::bail!("no feed with id {feed:?}");
|
anyhow::bail!("no feed with id {feed:?}");
|
||||||
}
|
}
|
||||||
ctx.cfg.save(config_path)?;
|
cfg.save(config_path)?;
|
||||||
// State and files stay: re-adding the feed should not re-download its back catalogue.
|
// State and files stay: re-adding the feed should not re-download its back catalogue.
|
||||||
println!("removed {feed}; downloads and history kept");
|
println!("removed {feed}; downloads and history kept");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn import(mut ctx: Ctx, config_path: &std::path::Path, file: &std::path::Path) -> Result<()> {
|
async fn import(ctx: Ctx, config_path: &std::path::Path, file: &std::path::Path) -> Result<()> {
|
||||||
|
let mut cfg = (*ctx.cfg()).clone();
|
||||||
let text = std::fs::read_to_string(file)
|
let text = std::fs::read_to_string(file)
|
||||||
.with_context(|| format!("reading {}", file.display()))?;
|
.with_context(|| format!("reading {}", file.display()))?;
|
||||||
let doc = opml::OPML::from_str(&text).map_err(|e| anyhow::anyhow!("parsing OPML: {e}"))?;
|
let doc = opml::OPML::from_str(&text).map_err(|e| anyhow::anyhow!("parsing OPML: {e}"))?;
|
||||||
@@ -295,12 +372,12 @@ async fn import(mut ctx: Ctx, config_path: &std::path::Path, file: &std::path::P
|
|||||||
|
|
||||||
let mut added = 0;
|
let mut added = 0;
|
||||||
for (title, url) in found {
|
for (title, url) in found {
|
||||||
if ctx.cfg.feeds.values().any(|f| f.url == url) {
|
if cfg.feeds.values().any(|f| f.url == url) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Name it from the OPML title rather than refetching every feed.
|
// Name it from the OPML title rather than refetching every feed.
|
||||||
let id = config::unique_slug(&title, &ctx.cfg.feeds);
|
let id = config::unique_slug(&title, &cfg.feeds);
|
||||||
ctx.cfg.feeds.insert(
|
cfg.feeds.insert(
|
||||||
id.clone(),
|
id.clone(),
|
||||||
config::Feed {
|
config::Feed {
|
||||||
url,
|
url,
|
||||||
@@ -317,7 +394,7 @@ async fn import(mut ctx: Ctx, config_path: &std::path::Path, file: &std::path::P
|
|||||||
println!("added {id}");
|
println!("added {id}");
|
||||||
added += 1;
|
added += 1;
|
||||||
}
|
}
|
||||||
ctx.cfg.save(config_path)?;
|
cfg.save(config_path)?;
|
||||||
println!("{added} feed(s) imported");
|
println!("{added} feed(s) imported");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -339,7 +416,7 @@ fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
|
|||||||
title: Some("ipx subscriptions".into()),
|
title: Some("ipx subscriptions".into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
for (id, feed) in &ctx.cfg.feeds {
|
for (id, feed) in &ctx.cfg().feeds {
|
||||||
let title = ctx
|
let title = ctx
|
||||||
.db
|
.db
|
||||||
.feed_summary(id)
|
.feed_summary(id)
|
||||||
@@ -350,16 +427,17 @@ fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
|
|||||||
}
|
}
|
||||||
let xml = doc.to_string().map_err(|e| anyhow::anyhow!("writing OPML: {e}"))?;
|
let xml = doc.to_string().map_err(|e| anyhow::anyhow!("writing OPML: {e}"))?;
|
||||||
std::fs::write(file, xml).with_context(|| format!("writing {}", file.display()))?;
|
std::fs::write(file, xml).with_context(|| format!("writing {}", file.display()))?;
|
||||||
println!("exported {} feed(s) to {}", ctx.cfg.feeds.len(), file.display());
|
println!("exported {} feed(s) to {}", ctx.cfg().feeds.len(), file.display());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list(ctx: &Ctx, config_path: &std::path::Path) -> Result<()> {
|
fn list(ctx: &Ctx, config_path: &std::path::Path) -> Result<()> {
|
||||||
if ctx.cfg.feeds.is_empty() {
|
let cfg = ctx.cfg();
|
||||||
|
if cfg.feeds.is_empty() {
|
||||||
println!("No feeds configured in {}", config_path.display());
|
println!("No feeds configured in {}", config_path.display());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
for (id, feed) in &ctx.cfg.feeds {
|
for (id, feed) in &cfg.feeds {
|
||||||
let s = ctx.db.feed_summary(id)?;
|
let s = ctx.db.feed_summary(id)?;
|
||||||
println!("{id} {}", s.title.as_deref().unwrap_or("-"));
|
println!("{id} {}", s.title.as_deref().unwrap_or("-"));
|
||||||
println!(" url {}", feed.url);
|
println!(" url {}", feed.url);
|
||||||
@@ -376,7 +454,7 @@ fn list(ctx: &Ctx, config_path: &std::path::Path) -> Result<()> {
|
|||||||
/// deleted, but must not emit the terminal ReapDone, or a client waiting on its `fetch`
|
/// deleted, but must not emit the terminal ReapDone, or a client waiting on its `fetch`
|
||||||
/// would stop reading before the scan had even started.
|
/// would stop reading before the scan had even started.
|
||||||
fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
|
fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
|
||||||
let r = retention::run(&ctx.cfg, &ctx.db, dry_run)?;
|
let r = retention::run(&ctx.cfg(), &ctx.db, dry_run)?;
|
||||||
for c in r.aged_out.iter().chain(r.over_quota.iter()) {
|
for c in r.aged_out.iter().chain(r.over_quota.iter()) {
|
||||||
ctx.out.emit(Event::Reaped {
|
ctx.out.emit(Event::Reaped {
|
||||||
path: c.path.clone(),
|
path: c.path.clone(),
|
||||||
@@ -393,15 +471,15 @@ fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch(ctx: &Ctx, only: Option<&str>, force: bool) -> Result<()> {
|
async fn fetch(ctx: &Ctx, only: Option<&str>, force: bool) -> Result<()> {
|
||||||
|
let cfg = ctx.cfg();
|
||||||
if let Some(id) = only
|
if let Some(id) = only
|
||||||
&& !ctx.cfg.feeds.contains_key(id)
|
&& !cfg.feeds.contains_key(id)
|
||||||
{
|
{
|
||||||
anyhow::bail!("no feed with id {id:?}");
|
anyhow::bail!("no feed with id {id:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut scanned = 0;
|
let mut scanned = 0;
|
||||||
for (id, feed_cfg) in ctx
|
for (id, feed_cfg) in cfg
|
||||||
.cfg
|
|
||||||
.feeds
|
.feeds
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(id, _)| only.is_none_or(|o| o == *id))
|
.filter(|(id, _)| only.is_none_or(|o| o == *id))
|
||||||
@@ -410,7 +488,7 @@ async fn fetch(ctx: &Ctx, only: Option<&str>, force: bool) -> Result<()> {
|
|||||||
|
|
||||||
// TTL: the feed's own <ttl> wins when it is longer than our poll interval.
|
// TTL: the feed's own <ttl> wins when it is longer than our poll interval.
|
||||||
if !force && let Some(last) = state.last_checked {
|
if !force && let Some(last) = state.last_checked {
|
||||||
let wait = state.ttl_mins.unwrap_or(0).max(ctx.cfg.general.interval_mins) * 60;
|
let wait = state.ttl_mins.unwrap_or(0).max(cfg.general.interval_mins) * 60;
|
||||||
let due = last + wait as i64;
|
let due = last + wait as i64;
|
||||||
if due > db::now() {
|
if due > db::now() {
|
||||||
ctx.out.emit(Event::FeedSkip {
|
ctx.out.emit(Event::FeedSkip {
|
||||||
@@ -507,12 +585,13 @@ async fn scan_one(
|
|||||||
|
|
||||||
let budget = feed_cfg.max_new_per_check.unwrap_or(usize::MAX);
|
let budget = feed_cfg.max_new_per_check.unwrap_or(usize::MAX);
|
||||||
if feed_cfg.auto_download && budget > 0 {
|
if feed_cfg.auto_download && budget > 0 {
|
||||||
let folder = download::folder_for(&ctx.cfg, id, feed_cfg, parsed.title.as_deref());
|
let cfg = ctx.cfg();
|
||||||
let dest_dir = ctx.cfg.general.download_dir.join(&folder);
|
let folder = download::folder_for(&cfg, id, feed_cfg, parsed.title.as_deref());
|
||||||
|
let dest_dir = cfg.general.download_dir.join(&folder);
|
||||||
|
|
||||||
for item in ctx.db.pending(id, budget)? {
|
for item in ctx.db.pending(id, budget)? {
|
||||||
if download::looks_like_torrent(&item.url, item.mime.as_deref()) {
|
if download::looks_like_torrent(&item.url, item.mime.as_deref()) {
|
||||||
if !ctx.cfg.torrent.enabled {
|
if !ctx.cfg().torrent.enabled {
|
||||||
ctx.db.mark_enclosure(&item.url, "skipped", Some("torrents disabled"))?;
|
ctx.db.mark_enclosure(&item.url, "skipped", Some("torrents disabled"))?;
|
||||||
ctx.out.emit(Event::TorrentDeferred {
|
ctx.out.emit(Event::TorrentDeferred {
|
||||||
feed: id.to_string(),
|
feed: id.to_string(),
|
||||||
@@ -603,7 +682,8 @@ async fn fetch_one(
|
|||||||
// Throttled to whole percents, as the original's lastDLStepSize guard did.
|
// Throttled to whole percents, as the original's lastDLStepSize guard did.
|
||||||
let mut last_pct = -1i64;
|
let mut last_pct = -1i64;
|
||||||
let name = download::filename_for(url, None);
|
let name = download::filename_for(url, None);
|
||||||
let got = download::download(&ctx.client, &ctx.cfg, feed_cfg, url, |done, total| {
|
let cfg = ctx.cfg();
|
||||||
|
let got = download::download(&ctx.client, &cfg, feed_cfg, url, |done, total| {
|
||||||
if let Some(t) = total.filter(|t| *t > 0) {
|
if let Some(t) = total.filter(|t| *t > 0) {
|
||||||
let pct = (done * 100 / t) as i64;
|
let pct = (done * 100 / t) as i64;
|
||||||
if pct > last_pct {
|
if pct > last_pct {
|
||||||
@@ -624,7 +704,7 @@ async fn fetch_one(
|
|||||||
// The MIME lied. Hand the URL to the torrent session instead of filing a .torrent
|
// The MIME lied. Hand the URL to the torrent session instead of filing a .torrent
|
||||||
// as if it were an episode.
|
// as if it were an episode.
|
||||||
let _ = tokio::fs::remove_file(&got.tmp).await;
|
let _ = tokio::fs::remove_file(&got.tmp).await;
|
||||||
if !ctx.cfg.torrent.enabled {
|
if !ctx.cfg().torrent.enabled {
|
||||||
ctx.db.mark_enclosure(url, "skipped", Some("torrents disabled"))?;
|
ctx.db.mark_enclosure(url, "skipped", Some("torrents disabled"))?;
|
||||||
anyhow::bail!("body is a torrent and torrents are disabled");
|
anyhow::bail!("body is a torrent and torrents are disabled");
|
||||||
}
|
}
|
||||||
@@ -646,9 +726,10 @@ async fn torrent_one(
|
|||||||
) -> Result<(PathBuf, u64)> {
|
) -> Result<(PathBuf, u64)> {
|
||||||
let name = download::filename_for(url, None);
|
let name = download::filename_for(url, None);
|
||||||
let mut last_pct = -1i64;
|
let mut last_pct = -1i64;
|
||||||
|
let cfg = ctx.cfg();
|
||||||
ctx.torrents()
|
ctx.torrents()
|
||||||
.await?
|
.await?
|
||||||
.fetch(&ctx.cfg, url, dest_dir, |done, total| {
|
.fetch(&cfg, url, dest_dir, |done, total| {
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
let pct = (done * 100 / total) as i64;
|
let pct = (done * 100 / total) as i64;
|
||||||
if pct > last_pct {
|
if pct > last_pct {
|
||||||
|
|||||||
437
src/web.rs
Normal file
437
src/web.rs
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
//! Web front end. Runs inside the daemon so it reads SQLite and the event bus directly.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
|
extract::{Path, Query, Request, State},
|
||||||
|
http::{StatusCode, header},
|
||||||
|
middleware::{self, Next},
|
||||||
|
response::{
|
||||||
|
Html, IntoResponse, Response,
|
||||||
|
sse::{Event as SseEvent, Sse},
|
||||||
|
},
|
||||||
|
routing::{delete, get, patch, post},
|
||||||
|
};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio_stream::wrappers::BroadcastStream;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
use tower_http::services::ServeFile;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
|
||||||
|
use crate::Ctx;
|
||||||
|
use crate::ipc::{Command, Event};
|
||||||
|
|
||||||
|
const COOKIE: &str = "ipx_token";
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct WebState {
|
||||||
|
pub ctx: Arc<Ctx>,
|
||||||
|
pub config_path: PathBuf,
|
||||||
|
pub cmds: mpsc::Sender<Command>,
|
||||||
|
pub events: broadcast::Sender<Event>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A 32-hex-character shared secret, generated when config.toml has none.
|
||||||
|
///
|
||||||
|
/// ponytail: /dev/urandom rather than a CSPRNG crate -- 16 bytes, once, on a Unix-only
|
||||||
|
/// binary. Falls back to the clock only if urandom is somehow unreadable, which would be a
|
||||||
|
/// weak token, so that case is logged loudly.
|
||||||
|
pub fn generate_token() -> String {
|
||||||
|
use std::io::Read;
|
||||||
|
let mut bytes = [0u8; 16];
|
||||||
|
match std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut bytes)) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "could not read /dev/urandom; token is NOT secure");
|
||||||
|
let n = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
bytes[..8].copy_from_slice(&n.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(state: WebState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(index))
|
||||||
|
.route("/api/feeds", get(feeds).post(add_feed))
|
||||||
|
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
|
||||||
|
.route("/api/feeds/{id}/entries", get(entries))
|
||||||
|
.route("/api/entries/{feed_id}/{guid}/flags", post(set_flags))
|
||||||
|
.route("/api/enclosures/{id}/download", post(download_now))
|
||||||
|
.route("/api/enclosures/{id}", delete(delete_file))
|
||||||
|
.route("/api/fetch", post(fetch_now))
|
||||||
|
.route("/api/events", get(events))
|
||||||
|
.route("/media/{id}", get(media))
|
||||||
|
.layer(middleware::from_fn_with_state(state.clone(), auth))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve(state: WebState, bind: &str) -> Result<()> {
|
||||||
|
let listener = tokio::net::TcpListener::bind(bind)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("binding {bind}"))?;
|
||||||
|
tracing::info!(bind, "web ui listening");
|
||||||
|
axum::serve(listener, router(state))
|
||||||
|
.await
|
||||||
|
.context("serving the web ui")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token in `?token=` (which then sets a cookie) or in the cookie itself.
|
||||||
|
///
|
||||||
|
/// It has to be a cookie rather than a header: an `<audio src>` request is issued by the
|
||||||
|
/// browser, and there is no way to attach a header to it.
|
||||||
|
async fn auth(State(state): State<WebState>, req: Request, next: Next) -> Response {
|
||||||
|
let expected = state.ctx.cfg().web.token.clone();
|
||||||
|
if expected.is_empty() {
|
||||||
|
// Refuse to serve rather than serve unauthenticated.
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "no web token configured").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let from_query = req.uri().query().and_then(|q| {
|
||||||
|
q.split('&')
|
||||||
|
.find_map(|kv| kv.strip_prefix("token=").map(str::to_owned))
|
||||||
|
});
|
||||||
|
let from_cookie = req
|
||||||
|
.headers()
|
||||||
|
.get(header::COOKIE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|c| {
|
||||||
|
c.split(';')
|
||||||
|
.find_map(|kv| kv.trim().strip_prefix(&format!("{COOKIE}=")).map(str::to_owned))
|
||||||
|
});
|
||||||
|
|
||||||
|
let supplied = from_query.clone().or(from_cookie);
|
||||||
|
if !supplied.is_some_and(|t| constant_time_eq(&t, &expected)) {
|
||||||
|
return (StatusCode::UNAUTHORIZED, "bad or missing token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut resp = next.run(req).await;
|
||||||
|
if from_query.is_some() {
|
||||||
|
// Remember it so the rest of the page (and the audio element) authenticates.
|
||||||
|
if let Ok(v) = header::HeaderValue::from_str(&format!(
|
||||||
|
"{COOKIE}={expected}; Path=/; SameSite=Lax; Max-Age=31536000"
|
||||||
|
)) {
|
||||||
|
resp.headers_mut().insert(header::SET_COOKIE, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compares without leaking length or position through timing.
|
||||||
|
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||||
|
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||||
|
if a.len() != b.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index() -> Html<&'static str> {
|
||||||
|
Html(include_str!("../web/index.html"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct FeedRow {
|
||||||
|
id: String,
|
||||||
|
url: String,
|
||||||
|
title: Option<String>,
|
||||||
|
folder: Option<String>,
|
||||||
|
keywords: Vec<String>,
|
||||||
|
allow_explicit: bool,
|
||||||
|
auto_download: bool,
|
||||||
|
max_new_per_check: Option<usize>,
|
||||||
|
last_checked: Option<i64>,
|
||||||
|
last_error: Option<String>,
|
||||||
|
entries: i64,
|
||||||
|
downloaded: i64,
|
||||||
|
unread: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn feeds(State(state): State<WebState>) -> Result<Json<Vec<FeedRow>>, ApiError> {
|
||||||
|
let cfg = state.ctx.cfg();
|
||||||
|
let mut out = Vec::with_capacity(cfg.feeds.len());
|
||||||
|
for (id, feed) in &cfg.feeds {
|
||||||
|
let s = state.ctx.db.feed_summary(id)?;
|
||||||
|
out.push(FeedRow {
|
||||||
|
id: id.clone(),
|
||||||
|
url: feed.url.clone(),
|
||||||
|
title: s.title,
|
||||||
|
folder: feed.folder.clone(),
|
||||||
|
keywords: feed.keywords.clone(),
|
||||||
|
allow_explicit: feed.allow_explicit,
|
||||||
|
auto_download: feed.auto_download,
|
||||||
|
max_new_per_check: feed.max_new_per_check,
|
||||||
|
last_checked: s.last_checked,
|
||||||
|
last_error: s.last_error,
|
||||||
|
entries: s.entries,
|
||||||
|
downloaded: s.downloaded,
|
||||||
|
unread: state.ctx.db.unread_count(id)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Json(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns anyhow errors into a 500 with a readable body.
|
||||||
|
pub struct ApiError(anyhow::Error);
|
||||||
|
|
||||||
|
impl<E: Into<anyhow::Error>> From<E> for ApiError {
|
||||||
|
fn from(e: E) -> Self {
|
||||||
|
Self(e.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
tracing::warn!(error = ?self.0, "api error");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("{:#}", self.0)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_comparison_rejects_mismatches_and_length_differences() {
|
||||||
|
assert!(constant_time_eq("abc123", "abc123"));
|
||||||
|
assert!(!constant_time_eq("abc123", "abc124"));
|
||||||
|
assert!(!constant_time_eq("abc", "abc123"));
|
||||||
|
assert!(!constant_time_eq("", "abc"));
|
||||||
|
assert!(constant_time_eq("", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_tokens_are_32_hex_chars_and_not_repeated() {
|
||||||
|
let a = generate_token();
|
||||||
|
let b = generate_token();
|
||||||
|
assert_eq!(a.len(), 32);
|
||||||
|
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
assert_ne!(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Page {
|
||||||
|
#[serde(default)]
|
||||||
|
offset: i64,
|
||||||
|
#[serde(default = "fifty")]
|
||||||
|
limit: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fifty() -> i64 {
|
||||||
|
50
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn entries(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Query(page): Query<Page>,
|
||||||
|
) -> Result<Json<Vec<crate::db::EntryRow>>, ApiError> {
|
||||||
|
let mut rows = state.ctx.db.entries(&id, page.offset, page.limit.clamp(1, 200))?;
|
||||||
|
// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
|
||||||
|
for row in &mut rows {
|
||||||
|
if let Some(d) = &row.description {
|
||||||
|
row.description = Some(ammonia::clean(d));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct NewFeed {
|
||||||
|
url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
folder: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
keywords: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_feed(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
Json(body): Json<NewFeed>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let mut cfg = (*state.ctx.cfg()).clone();
|
||||||
|
if let Some((id, _)) = cfg.feeds.iter().find(|(_, f)| f.url == body.url) {
|
||||||
|
return Ok(Json(serde_json::json!({ "id": id, "existing": true })));
|
||||||
|
}
|
||||||
|
let id = crate::add_one(&state.ctx, &mut cfg, &body.url, body.folder, body.keywords).await?;
|
||||||
|
cfg.save(&state.config_path)?;
|
||||||
|
state.ctx.reload_cfg(&state.config_path)?;
|
||||||
|
Ok(Json(serde_json::json!({ "id": id, "existing": false })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the fields that are present are changed.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FeedPatch {
|
||||||
|
folder: Option<Option<String>>,
|
||||||
|
keywords: Option<Vec<String>>,
|
||||||
|
allow_explicit: Option<bool>,
|
||||||
|
auto_download: Option<bool>,
|
||||||
|
max_new_per_check: Option<Option<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_feed(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(body): Json<FeedPatch>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let mut cfg = (*state.ctx.cfg()).clone();
|
||||||
|
let feed = cfg
|
||||||
|
.feeds
|
||||||
|
.get_mut(&id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no feed with id {id:?}"))?;
|
||||||
|
|
||||||
|
if let Some(v) = body.folder {
|
||||||
|
feed.folder = v.filter(|s| !s.trim().is_empty());
|
||||||
|
}
|
||||||
|
if let Some(v) = body.keywords {
|
||||||
|
feed.keywords = v.into_iter().filter(|k| !k.trim().is_empty()).collect();
|
||||||
|
}
|
||||||
|
if let Some(v) = body.allow_explicit {
|
||||||
|
feed.allow_explicit = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = body.auto_download {
|
||||||
|
feed.auto_download = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = body.max_new_per_check {
|
||||||
|
feed.max_new_per_check = v;
|
||||||
|
}
|
||||||
|
cfg.save(&state.config_path)?;
|
||||||
|
state.ctx.reload_cfg(&state.config_path)?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_feed(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let mut cfg = (*state.ctx.cfg()).clone();
|
||||||
|
if cfg.feeds.remove(&id).is_none() {
|
||||||
|
return Err(anyhow::anyhow!("no feed with id {id:?}").into());
|
||||||
|
}
|
||||||
|
// Downloads and history stay, so re-adding does not re-pull the back catalogue.
|
||||||
|
cfg.save(&state.config_path)?;
|
||||||
|
state.ctx.reload_cfg(&state.config_path)?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Flags {
|
||||||
|
read: Option<bool>,
|
||||||
|
flagged: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_flags(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
Path((feed_id, guid)): Path<(String, String)>,
|
||||||
|
Json(body): Json<Flags>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
use crate::db::EntryFlag;
|
||||||
|
if let Some(v) = body.read {
|
||||||
|
state.ctx.db.set_entry_flag(&feed_id, &guid, EntryFlag::Read, v)?;
|
||||||
|
}
|
||||||
|
if let Some(v) = body.flagged {
|
||||||
|
state.ctx.db.set_entry_flag(&feed_id, &guid, EntryFlag::Flagged, v)?;
|
||||||
|
}
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts one enclosure back in the queue and kicks a scan of its feed. The queue is the
|
||||||
|
/// table, so this is all it takes -- including for something a filter once skipped.
|
||||||
|
async fn download_now(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let enc = state
|
||||||
|
.ctx
|
||||||
|
.db
|
||||||
|
.enclosure(id)?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
|
||||||
|
if enc.path.is_some() {
|
||||||
|
return Ok(StatusCode::NO_CONTENT); // Already here.
|
||||||
|
}
|
||||||
|
state.ctx.db.requeue(id)?;
|
||||||
|
let _ = state
|
||||||
|
.cmds
|
||||||
|
.send(Command::Fetch { feed: Some(enc.feed_id), force: true })
|
||||||
|
.await;
|
||||||
|
Ok(StatusCode::ACCEPTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_file(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let enc = state
|
||||||
|
.ctx
|
||||||
|
.db
|
||||||
|
.enclosure(id)?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
|
||||||
|
if let Some(path) = &enc.path
|
||||||
|
&& let Err(e) = std::fs::remove_file(path)
|
||||||
|
&& e.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
// The row survives as 'reaped', which is what stops the next scan re-downloading it.
|
||||||
|
state.ctx.db.mark_reaped(id)?;
|
||||||
|
state.events.send(Event::Reaped {
|
||||||
|
path: enc.path.unwrap_or_default(),
|
||||||
|
bytes: enc.length.unwrap_or(0).max(0) as u64,
|
||||||
|
}).ok();
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FetchBody {
|
||||||
|
#[serde(default)]
|
||||||
|
feed: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_now(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
Json(body): Json<FetchBody>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
state
|
||||||
|
.cmds
|
||||||
|
.send(Command::Fetch { feed: body.feed, force: body.force })
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
|
||||||
|
Ok(StatusCode::ACCEPTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same broadcast the socket clients read, as server-sent events.
|
||||||
|
async fn events(State(state): State<WebState>) -> Sse<impl futures_util::Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
|
||||||
|
let stream = BroadcastStream::new(state.events.subscribe()).filter_map(|ev| async move {
|
||||||
|
let ev = ev.ok()?;
|
||||||
|
Some(Ok(SseEvent::default().data(serde_json::to_string(&ev).ok()?)))
|
||||||
|
});
|
||||||
|
Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audio, served by ServeFile so Range requests work and the player can seek.
|
||||||
|
async fn media(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
req: Request,
|
||||||
|
) -> Response {
|
||||||
|
let Ok(Some(enc)) = state.ctx.db.enclosure(id) else {
|
||||||
|
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
|
||||||
|
};
|
||||||
|
let Some(path) = enc.path else {
|
||||||
|
return (StatusCode::NOT_FOUND, "not downloaded").into_response();
|
||||||
|
};
|
||||||
|
match ServeFile::new(path).oneshot(req).await {
|
||||||
|
Ok(r) => r.into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
316
web/index.html
Normal file
316
web/index.html
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>ipx</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #14161a; --panel: #1c1f26; --panel2: #23272f; --line: #2e333d;
|
||||||
|
--fg: #e6e8ec; --dim: #9aa2b1; --accent: #6ea8fe; --good: #5fd08a;
|
||||||
|
--bad: #f4776a;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; background: var(--bg); color: var(--fg);
|
||||||
|
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex; align-items: center; gap: 12px; padding: 10px 16px;
|
||||||
|
background: var(--panel); border-bottom: 1px solid var(--line);
|
||||||
|
position: sticky; top: 0; z-index: 5;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 16px; margin: 0; letter-spacing: .06em; text-transform: uppercase; color: var(--dim); }
|
||||||
|
#status { margin-left: auto; color: var(--dim); font-size: 13px; min-height: 1em; }
|
||||||
|
button {
|
||||||
|
background: var(--panel2); color: var(--fg); border: 1px solid var(--line);
|
||||||
|
border-radius: 6px; padding: 5px 10px; cursor: pointer; font-size: 13px;
|
||||||
|
}
|
||||||
|
button:hover { border-color: var(--accent); }
|
||||||
|
button.primary { background: var(--accent); color: #10131a; border-color: var(--accent); font-weight: 600; }
|
||||||
|
button.danger:hover { border-color: var(--bad); color: var(--bad); }
|
||||||
|
main { display: grid; grid-template-columns: 300px 1fr; min-height: calc(100vh - 49px); }
|
||||||
|
#feeds { background: var(--panel); border-right: 1px solid var(--line); padding: 8px; }
|
||||||
|
.feed {
|
||||||
|
padding: 8px 10px; border-radius: 6px; cursor: pointer; margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.feed:hover { background: var(--panel2); }
|
||||||
|
.feed.sel { background: var(--panel2); box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
|
.feed .name { display: flex; gap: 8px; align-items: baseline; }
|
||||||
|
.feed .name b { font-weight: 600; font-size: 14px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.pill { background: var(--accent); color: #10131a; border-radius: 10px; padding: 0 7px; font-size: 11px; font-weight: 700; }
|
||||||
|
.feed small, .meta { color: var(--dim); font-size: 12px; }
|
||||||
|
.err { color: var(--bad); font-size: 12px; }
|
||||||
|
#content { padding: 16px 20px; max-width: 900px; }
|
||||||
|
.entry { border: 1px solid var(--line); border-radius: 8px; margin-bottom: 8px; background: var(--panel); }
|
||||||
|
.entry.unread { border-left: 3px solid var(--accent); }
|
||||||
|
.head { padding: 10px 12px; cursor: pointer; display: flex; gap: 10px; align-items: baseline; }
|
||||||
|
.head h3 { margin: 0; font-size: 15px; font-weight: 600; flex: 1; }
|
||||||
|
.body { padding: 0 12px 12px; border-top: 1px solid var(--line); }
|
||||||
|
.desc { color: #cfd4dd; font-size: 14px; overflow-wrap: anywhere; }
|
||||||
|
.desc img { max-width: 100%; height: auto; }
|
||||||
|
.desc a { color: var(--accent); }
|
||||||
|
audio { width: 100%; margin: 10px 0; }
|
||||||
|
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 8px; }
|
||||||
|
.bar { height: 4px; background: var(--panel2); border-radius: 2px; overflow: hidden; margin-top: 6px; }
|
||||||
|
.bar i { display: block; height: 100%; background: var(--accent); width: 0; transition: width .2s; }
|
||||||
|
.state { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--dim); }
|
||||||
|
.state.done { color: var(--good); }
|
||||||
|
.state.error { color: var(--bad); }
|
||||||
|
form.settings { display: grid; gap: 8px; margin-top: 10px; }
|
||||||
|
form.settings label { display: grid; gap: 3px; font-size: 12px; color: var(--dim); }
|
||||||
|
input[type=text], input[type=number] {
|
||||||
|
background: var(--bg); border: 1px solid var(--line); color: var(--fg);
|
||||||
|
border-radius: 6px; padding: 6px 8px; font-size: 14px; width: 100%;
|
||||||
|
}
|
||||||
|
.checks { display: flex; gap: 16px; font-size: 13px; color: var(--fg); }
|
||||||
|
.checks label { flex-direction: row; align-items: center; gap: 6px; color: var(--fg); }
|
||||||
|
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px; margin-bottom: 12px; }
|
||||||
|
h2 { font-size: 17px; margin: 0 0 2px; }
|
||||||
|
.empty { color: var(--dim); padding: 24px 0; }
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
main { grid-template-columns: 1fr; }
|
||||||
|
#feeds { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>ipx</h1>
|
||||||
|
<button id="fetchAll">Scan all</button>
|
||||||
|
<button id="showAdd">+ Feed</button>
|
||||||
|
<span id="status"></span>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<nav id="feeds"></nav>
|
||||||
|
<section id="content"><p class="empty">Pick a feed.</p></section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (s, r = document) => r.querySelector(s);
|
||||||
|
const api = async (url, opts) => {
|
||||||
|
const r = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts });
|
||||||
|
if (!r.ok) throw new Error((await r.text()) || r.status);
|
||||||
|
return r.status === 204 ? null : r.json().catch(() => null);
|
||||||
|
};
|
||||||
|
const esc = s => (s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
const when = t => t ? new Date(t * 1000).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '';
|
||||||
|
const mb = n => n ? (n / 1048576).toFixed(1) + ' MB' : '';
|
||||||
|
const say = (m) => { $('#status').textContent = m; };
|
||||||
|
|
||||||
|
let feeds = [], current = null, entries = [], open = new Set();
|
||||||
|
|
||||||
|
async function loadFeeds() {
|
||||||
|
feeds = await api('/api/feeds');
|
||||||
|
const nav = $('#feeds');
|
||||||
|
nav.innerHTML = '';
|
||||||
|
if (!feeds.length) { nav.innerHTML = '<p class="empty" style="padding:10px">No feeds yet.</p>'; return; }
|
||||||
|
for (const f of feeds) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'feed' + (current === f.id ? ' sel' : '');
|
||||||
|
el.innerHTML = `<div class="name"><b>${esc(f.title || f.id)}</b>` +
|
||||||
|
(f.unread ? `<span class="pill">${f.unread}</span>` : '') + `</div>` +
|
||||||
|
`<small>${f.entries} entries · ${f.downloaded} downloaded</small>` +
|
||||||
|
(f.last_error ? `<div class="err">${esc(f.last_error)}</div>` : '');
|
||||||
|
el.onclick = () => selectFeed(f.id);
|
||||||
|
nav.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectFeed(id) {
|
||||||
|
current = id; open.clear();
|
||||||
|
await loadFeeds();
|
||||||
|
await loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEntries() {
|
||||||
|
const f = feeds.find(x => x.id === current);
|
||||||
|
if (!f) return;
|
||||||
|
entries = await api(`/api/feeds/${encodeURIComponent(current)}/entries?limit=100`);
|
||||||
|
render(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(f) {
|
||||||
|
const c = $('#content');
|
||||||
|
c.innerHTML = `
|
||||||
|
<div class="panel">
|
||||||
|
<h2>${esc(f.title || f.id)}</h2>
|
||||||
|
<div class="meta">${esc(f.url)}</div>
|
||||||
|
<div class="row">
|
||||||
|
<button onclick="scan('${f.id}')">Scan now</button>
|
||||||
|
<button onclick="toggleSettings()">Settings</button>
|
||||||
|
<button class="danger" onclick="removeFeed('${f.id}')">Unsubscribe</button>
|
||||||
|
</div>
|
||||||
|
<div id="settings" hidden></div>
|
||||||
|
</div>
|
||||||
|
<div id="list"></div>`;
|
||||||
|
const list = $('#list');
|
||||||
|
if (!entries.length) { list.innerHTML = '<p class="empty">No entries yet — try Scan now.</p>'; return; }
|
||||||
|
for (const e of entries) list.appendChild(entryEl(f, e));
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryEl(f, e) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'entry' + (e.read ? '' : ' unread');
|
||||||
|
div.dataset.guid = e.guid;
|
||||||
|
const isOpen = open.has(e.guid);
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="head">
|
||||||
|
<h3>${esc(e.title || '(untitled)')}</h3>
|
||||||
|
<span class="meta">${when(e.published)}</span>
|
||||||
|
<span title="Keep this episode" style="cursor:pointer">${e.flagged ? '★' : '☆'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="body" ${isOpen ? '' : 'hidden'}></div>`;
|
||||||
|
const head = $('.head', div), body = $('.body', div);
|
||||||
|
head.querySelector('span[title]').onclick = ev => { ev.stopPropagation(); flag(f, e, !e.flagged); };
|
||||||
|
head.onclick = () => {
|
||||||
|
const nowOpen = body.hidden;
|
||||||
|
body.hidden = !nowOpen;
|
||||||
|
if (nowOpen) { open.add(e.guid); fillBody(body, f, e); } else { open.delete(e.guid); }
|
||||||
|
};
|
||||||
|
if (isOpen) fillBody(body, f, e);
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillBody(body, f, e) {
|
||||||
|
// description is sanitized server-side with ammonia before it ever gets here
|
||||||
|
const encs = e.enclosures.map(x => encEl(f, e, x)).join('');
|
||||||
|
body.innerHTML = `<div class="desc">${e.description || '<em>No show notes.</em>'}</div>
|
||||||
|
${encs}
|
||||||
|
<div class="row">
|
||||||
|
<button onclick="markRead('${f.id}', ${JSON.stringify(e.guid).replace(/"/g, '"')}, ${!e.read})">
|
||||||
|
Mark ${e.read ? 'unread' : 'read'}</button>
|
||||||
|
${e.link ? `<a class="meta" href="${esc(e.link)}" target="_blank" rel="noreferrer noopener">Open original</a>` : ''}
|
||||||
|
</div>`;
|
||||||
|
for (const x of e.enclosures) {
|
||||||
|
const audio = $(`#audio-${x.id}`, body);
|
||||||
|
// Playing something is the clearest signal it has been listened to, and retention
|
||||||
|
// deletes read episodes before unread ones.
|
||||||
|
if (audio) audio.onplay = () => { if (!e.read) markRead(f.id, e.guid, true, true); };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encEl(f, e, x) {
|
||||||
|
const g = JSON.stringify(e.guid).replace(/"/g, '"');
|
||||||
|
if (x.path) {
|
||||||
|
return `<div>
|
||||||
|
<audio id="audio-${x.id}" controls preload="none" src="/media/${x.id}"></audio>
|
||||||
|
<div class="row">
|
||||||
|
<span class="state done">downloaded</span><span class="meta">${mb(x.length)}</span>
|
||||||
|
<a class="meta" href="/media/${x.id}" download>Save file</a>
|
||||||
|
<button class="danger" onclick="delFile(${x.id})">Delete file</button>
|
||||||
|
</div></div>`;
|
||||||
|
}
|
||||||
|
return `<div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="state ${esc(x.state)}">${esc(x.state)}</span>
|
||||||
|
<span class="meta">${mb(x.length)}</span>
|
||||||
|
<button onclick="dl(${x.id})">Download</button>
|
||||||
|
${x.last_error ? `<span class="err">${esc(x.last_error)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="bar" id="bar-${x.id}"><i></i></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markRead(feedId, guid, read, quiet) {
|
||||||
|
await api(`/api/entries/${encodeURIComponent(feedId)}/${encodeURIComponent(guid)}/flags`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ read }) });
|
||||||
|
const e = entries.find(x => x.guid === guid); if (e) e.read = read;
|
||||||
|
if (!quiet) { const f = feeds.find(x => x.id === current); render(f); }
|
||||||
|
loadFeeds();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flag(f, e, on) {
|
||||||
|
await api(`/api/entries/${encodeURIComponent(f.id)}/${encodeURIComponent(e.guid)}/flags`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ flagged: on }) });
|
||||||
|
e.flagged = on; render(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dl(id) { say('Queued…'); await api(`/api/enclosures/${id}/download`, { method: 'POST' }); }
|
||||||
|
async function delFile(id) {
|
||||||
|
if (!confirm('Delete this file? The episode stays listed and will not be re-downloaded.')) return;
|
||||||
|
await api(`/api/enclosures/${id}`, { method: 'DELETE' });
|
||||||
|
loadEntries(); loadFeeds();
|
||||||
|
}
|
||||||
|
async function scan(id) { say('Scanning…'); await api('/api/fetch', { method: 'POST', body: JSON.stringify({ feed: id, force: true }) }); }
|
||||||
|
async function removeFeed(id) {
|
||||||
|
if (!confirm(`Unsubscribe from ${id}? Downloads and history are kept.`)) return;
|
||||||
|
await api(`/api/feeds/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
current = null; $('#content').innerHTML = '<p class="empty">Pick a feed.</p>'; loadFeeds();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSettings() {
|
||||||
|
const box = $('#settings'), f = feeds.find(x => x.id === current);
|
||||||
|
box.hidden = !box.hidden;
|
||||||
|
if (box.hidden) return;
|
||||||
|
box.innerHTML = `<form class="settings" onsubmit="return saveSettings(event)">
|
||||||
|
<label>Folder<input type="text" name="folder" value="${esc(f.folder || '')}" placeholder="${esc(f.title || f.id)}"></label>
|
||||||
|
<label>Keywords (comma separated; leave empty to take everything)
|
||||||
|
<input type="text" name="keywords" value="${esc(f.keywords.join(', '))}"></label>
|
||||||
|
<label>Max new downloads per scan (blank = no limit)
|
||||||
|
<input type="number" name="max" min="0" value="${f.max_new_per_check ?? ''}"></label>
|
||||||
|
<div class="checks">
|
||||||
|
<label><input type="checkbox" name="explicit" ${f.allow_explicit ? 'checked' : ''}> Allow explicit</label>
|
||||||
|
<label><input type="checkbox" name="auto" ${f.auto_download ? 'checked' : ''}> Auto download</label>
|
||||||
|
</div>
|
||||||
|
<div class="row"><button class="primary" type="submit">Save</button></div>
|
||||||
|
</form>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const d = new FormData(ev.target);
|
||||||
|
const max = d.get('max');
|
||||||
|
await api(`/api/feeds/${encodeURIComponent(current)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({
|
||||||
|
folder: d.get('folder').trim() || null,
|
||||||
|
keywords: d.get('keywords').split(',').map(s => s.trim()).filter(Boolean),
|
||||||
|
max_new_per_check: max === '' ? null : Number(max),
|
||||||
|
allow_explicit: d.get('explicit') === 'on',
|
||||||
|
auto_download: d.get('auto') === 'on',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
say('Saved — applies to the next scan, no restart needed.');
|
||||||
|
await loadFeeds();
|
||||||
|
const f = feeds.find(x => x.id === current);
|
||||||
|
render(f);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#fetchAll').onclick = () => { say('Scanning all feeds…'); api('/api/fetch', { method: 'POST', body: JSON.stringify({ force: true }) }); };
|
||||||
|
$('#showAdd').onclick = async () => {
|
||||||
|
const url = prompt('Feed URL');
|
||||||
|
if (!url) return;
|
||||||
|
say('Adding…');
|
||||||
|
try { const r = await api('/api/feeds', { method: 'POST', body: JSON.stringify({ url }) });
|
||||||
|
say(r.existing ? `Already subscribed as ${r.id}` : `Added ${r.id}`);
|
||||||
|
await loadFeeds(); selectFeed(r.id);
|
||||||
|
} catch (e) { say('Failed: ' + e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Live events from the same broadcast bus the socket clients read.
|
||||||
|
const sse = new EventSource('/api/events');
|
||||||
|
sse.onmessage = m => {
|
||||||
|
const ev = JSON.parse(m.data);
|
||||||
|
if (ev.ev === 'progress') {
|
||||||
|
say(`${ev.file}: ${((ev.done / (ev.total || ev.done)) * 100).toFixed(0)}%`);
|
||||||
|
const bars = document.querySelectorAll('.bar i');
|
||||||
|
for (const b of bars) if (b.parentElement.id) b.style.width = ((ev.done / (ev.total || 1)) * 100) + '%';
|
||||||
|
} else if (ev.ev === 'download_done') {
|
||||||
|
say('Downloaded ' + ev.path.split('/').pop());
|
||||||
|
loadEntries(); loadFeeds();
|
||||||
|
} else if (ev.ev === 'feed_done') {
|
||||||
|
say(`${ev.feed}: ${ev.new} new, ${ev.downloaded} downloaded`);
|
||||||
|
loadEntries(); loadFeeds();
|
||||||
|
} else if (ev.ev === 'scan_done') {
|
||||||
|
say('Scan complete.'); loadFeeds();
|
||||||
|
} else if (ev.ev === 'feed_error' || ev.ev === 'download_error') {
|
||||||
|
say('Error: ' + ev.msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadFeeds();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user