Upload date in UI; extract HTML_UI; Twitch clips mode; DB connection pool
Four nice-to-have items, plus surfacing the upload date in both UIs
since the user explicitly asked.
Upload date display
- Desktop: card meta row now includes a `YYYY-MM-DD` cell between the
ID and the duration; detail panel header shows it as `📅 YYYY-MM-DD`.
- Web: video player modal title gains a date+duration subline so the
viewer has context without opening the metadata pane (card meta
already shows the date).
- New `format_upload_date()` helper on the desktop side; the web side
reuses the existing `fmtDate()` JS.
Extract HTML_UI to include_str!
- `LOGIN_HTML` and `HTML_UI` moved out of `src/web.rs` into
`src/web_ui/login.html` and `src/web_ui/index.html`. The Rust file
drops from 2915 lines to 1806; editors now syntax-highlight the
HTML/CSS/JS, and UI diffs no longer overwhelm the Rust diff view.
- `include_str!` bakes them into the binary at compile time, so there
is no runtime fs lookup and no extra deploy artifacts.
Twitch clips-only mode
- `classify_twitch` now recognises `twitch.tv/<user>/clips` as a
Channel URL (was Unknown), so yt-dlp's TwitchClips extractor is
reachable from our normal download pipeline.
- Both UIs gain a "Clips only" toggle visible only when the URL is
a Twitch channel (not a specific VOD/clip). On submit, the URL is
rewritten to `twitch.tv/<user>/clips` before dispatch.
- 1 new platform test covers the clips-listing classification.
DB connection pool (r2d2_sqlite)
- `Database` now wraps an `r2d2`-managed SQLite pool. File-backed
databases get up to 4 connections; the in-memory fallback is capped
at 1 (since each in-memory connection is a fresh empty DB by default
in SQLite).
- `WebState.db` drops its outer `Mutex` — concurrent handlers each
check out their own connection from the pool. Static-file fetches
through `/files/` no longer serialize on the watched/positions/
password lookups that the auth middleware performs.
- Pool init failures are converted to `rusqlite::Error` so the
existing `Result<T>` return types still work.
47 unit tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8d1c274075
commit
375a24262c
8 changed files with 1428 additions and 1151 deletions
108
Cargo.lock
generated
108
Cargo.lock
generated
|
|
@ -192,7 +192,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"blake2",
|
"blake2",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"password-hash",
|
"password-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -268,6 +268,18 @@ dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-compression"
|
||||||
|
version = "0.4.42"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
|
||||||
|
dependencies = [
|
||||||
|
"compression-codecs",
|
||||||
|
"compression-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-executor"
|
name = "async-executor"
|
||||||
version = "1.14.0"
|
version = "1.14.0"
|
||||||
|
|
@ -732,6 +744,17 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.0",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clipboard-win"
|
name = "clipboard-win"
|
||||||
version = "5.4.1"
|
version = "5.4.1"
|
||||||
|
|
@ -792,6 +815,23 @@ dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-codecs"
|
||||||
|
version = "0.4.38"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
|
||||||
|
dependencies = [
|
||||||
|
"compression-core",
|
||||||
|
"flate2",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-core"
|
||||||
|
version = "0.4.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
|
@ -860,6 +900,15 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
|
|
@ -1395,6 +1444,7 @@ dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
|
"rand_core 0.10.1",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
|
|
@ -2873,6 +2923,28 @@ version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r2d2"
|
||||||
|
version = "0.8.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"parking_lot",
|
||||||
|
"scheduled-thread-pool",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r2d2_sqlite"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a982edf65c129796dba72f8775b292ef482b40d035e827a9825b3bc07ccc5f2"
|
||||||
|
dependencies = [
|
||||||
|
"r2d2",
|
||||||
|
"rusqlite",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
|
|
@ -2894,6 +2966,17 @@ dependencies = [
|
||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
|
||||||
|
dependencies = [
|
||||||
|
"chacha20",
|
||||||
|
"getrandom 0.4.2",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
|
@ -2932,6 +3015,12 @@ dependencies = [
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
|
|
@ -3077,6 +3166,15 @@ dependencies = [
|
||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scheduled-thread-pool"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19"
|
||||||
|
dependencies = [
|
||||||
|
"parking_lot",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scoped-tls"
|
name = "scoped-tls"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
|
|
@ -3201,7 +3299,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -3685,8 +3783,10 @@ version = "0.5.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
|
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-compression",
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
|
|
@ -3841,7 +3941,9 @@ version = "1.23.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"getrandom 0.4.2",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
"rand 0.10.1",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
@ -4939,6 +5041,8 @@ dependencies = [
|
||||||
"eframe",
|
"eframe",
|
||||||
"image",
|
"image",
|
||||||
"notify-rust",
|
"notify-rust",
|
||||||
|
"r2d2",
|
||||||
|
"r2d2_sqlite",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"rfd",
|
"rfd",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ toml = "0.8"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
|
r2d2 = "0.8"
|
||||||
|
r2d2_sqlite = "0.24"
|
||||||
notify-rust = { version = "4", default-features = false, features = ["z"] }
|
notify-rust = { version = "4", default-features = false, features = ["z"] }
|
||||||
axum = { version = "0.7", features = ["macros"] }
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
|
||||||
54
src/app.rs
54
src/app.rs
|
|
@ -88,6 +88,10 @@ pub struct App {
|
||||||
dl_music_mode: bool,
|
dl_music_mode: bool,
|
||||||
/// Record an ongoing live stream from the start instead of joining live.
|
/// Record an ongoing live stream from the start instead of joining live.
|
||||||
dl_live: bool,
|
dl_live: bool,
|
||||||
|
/// For Twitch channel URLs: target the `/clips` listing instead of the
|
||||||
|
/// default profile (VODs / past broadcasts). Has no effect on other
|
||||||
|
/// platforms or on single-video URLs.
|
||||||
|
dl_twitch_clips: bool,
|
||||||
textures: HashMap<PathBuf, Option<egui::TextureHandle>>,
|
textures: HashMap<PathBuf, Option<egui::TextureHandle>>,
|
||||||
thumb_request_tx: Sender<PathBuf>,
|
thumb_request_tx: Sender<PathBuf>,
|
||||||
thumb_result_rx: Receiver<(PathBuf, Option<egui::ColorImage>)>,
|
thumb_result_rx: Receiver<(PathBuf, Option<egui::ColorImage>)>,
|
||||||
|
|
@ -225,6 +229,7 @@ impl App {
|
||||||
dl_quality: DownloadQuality::Best,
|
dl_quality: DownloadQuality::Best,
|
||||||
dl_music_mode: false,
|
dl_music_mode: false,
|
||||||
dl_live: false,
|
dl_live: false,
|
||||||
|
dl_twitch_clips: false,
|
||||||
textures: HashMap::new(),
|
textures: HashMap::new(),
|
||||||
thumb_request_tx,
|
thumb_request_tx,
|
||||||
thumb_result_rx,
|
thumb_result_rx,
|
||||||
|
|
@ -947,11 +952,32 @@ impl App {
|
||||||
so yt-dlp records from the beginning instead of joining mid-stream. \
|
so yt-dlp records from the beginning instead of joining mid-stream. \
|
||||||
Also waits if the stream hasn't begun yet.",
|
Also waits if the stream hasn't begun yet.",
|
||||||
);
|
);
|
||||||
|
if info.platform == Platform::Twitch
|
||||||
|
&& matches!(info.kind, UrlKind::Channel { .. })
|
||||||
|
{
|
||||||
|
ui.checkbox(&mut self.dl_twitch_clips, "Clips only (Twitch)")
|
||||||
|
.on_hover_text(
|
||||||
|
"Pull only the channel's Clips section instead of the \
|
||||||
|
default VOD/highlights mix. Rewrites the URL to \
|
||||||
|
twitch.tv/<channel>/clips before submitting.",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let ready = !self.dl_url.trim().is_empty();
|
let ready = !self.dl_url.trim().is_empty();
|
||||||
if ui.add_enabled(ready, egui::Button::new("⬇ Start download")).clicked() {
|
if ui.add_enabled(ready, egui::Button::new("⬇ Start download")).clicked() {
|
||||||
let url = self.dl_url.trim().to_string();
|
let mut url = self.dl_url.trim().to_string();
|
||||||
|
// Twitch clips-only: rewrite `twitch.tv/<user>` to
|
||||||
|
// `twitch.tv/<user>/clips`. yt-dlp's TwitchClips extractor
|
||||||
|
// handles the rest and we still classify it as Channel so
|
||||||
|
// the output folder is unchanged.
|
||||||
|
if self.dl_twitch_clips
|
||||||
|
&& info.platform == Platform::Twitch
|
||||||
|
&& matches!(info.kind, UrlKind::Channel { .. })
|
||||||
|
&& !url.contains("/clips")
|
||||||
|
{
|
||||||
|
url = format!("{}/clips", url.trim_end_matches('/'));
|
||||||
|
}
|
||||||
let dest = dest_preview.clone();
|
let dest = dest_preview.clone();
|
||||||
if self.dl_music_mode {
|
if self.dl_music_mode {
|
||||||
self.downloader.start_music(url);
|
self.downloader.start_music(url);
|
||||||
|
|
@ -1708,6 +1734,16 @@ impl App {
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
|
if let Some(date) = video.upload_date.as_deref().map(format_upload_date) {
|
||||||
|
if !date.is_empty() {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!("📅 {date}"))
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
ui.label(egui::RichText::new("·").weak());
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(secs) = video.duration_secs {
|
if let Some(secs) = video.duration_secs {
|
||||||
ui.label(egui::RichText::new(format_duration(secs)).small().weak());
|
ui.label(egui::RichText::new(format_duration(secs)).small().weak());
|
||||||
ui.label(egui::RichText::new("·").weak());
|
ui.label(egui::RichText::new("·").weak());
|
||||||
|
|
@ -2076,6 +2112,12 @@ impl App {
|
||||||
ui.label(egui::RichText::new("·").weak());
|
ui.label(egui::RichText::new("·").weak());
|
||||||
}
|
}
|
||||||
ui.label(egui::RichText::new(&card.id).small().monospace().weak());
|
ui.label(egui::RichText::new(&card.id).small().monospace().weak());
|
||||||
|
if let Some(date) = card.upload_date.as_deref().map(format_upload_date) {
|
||||||
|
if !date.is_empty() {
|
||||||
|
ui.label(egui::RichText::new("·").weak());
|
||||||
|
ui.label(egui::RichText::new(date).small().weak());
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(secs) = card.duration_secs {
|
if let Some(secs) = card.duration_secs {
|
||||||
ui.label(egui::RichText::new("·").weak());
|
ui.label(egui::RichText::new("·").weak());
|
||||||
ui.label(egui::RichText::new(format_duration(secs)).small().weak());
|
ui.label(egui::RichText::new(format_duration(secs)).small().weak());
|
||||||
|
|
@ -2306,6 +2348,16 @@ fn format_duration(secs: f64) -> String {
|
||||||
if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") }
|
if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert yt-dlp's native `YYYYMMDD` upload-date string into `YYYY-MM-DD`
|
||||||
|
/// for display. Returns an empty string when input doesn't look like a date
|
||||||
|
/// so callers can decide whether to skip the row entirely.
|
||||||
|
fn format_upload_date(yyyymmdd: &str) -> String {
|
||||||
|
if yyyymmdd.len() < 8 || !yyyymmdd.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
format!("{}-{}-{}", &yyyymmdd[..4], &yyyymmdd[4..6], &yyyymmdd[6..8])
|
||||||
|
}
|
||||||
|
|
||||||
fn format_size(bytes: u64) -> String {
|
fn format_size(bytes: u64) -> String {
|
||||||
if bytes >= 1_073_741_824 {
|
if bytes >= 1_073_741_824 {
|
||||||
format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
|
format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
|
||||||
|
|
|
||||||
103
src/database.rs
103
src/database.rs
|
|
@ -1,6 +1,10 @@
|
||||||
//! Persistent storage for watched status and playback positions.
|
//! Persistent storage for watched status, playback positions, and settings.
|
||||||
//!
|
//!
|
||||||
//! Uses a bundled SQLite database (`yt-offline.db` by default).
|
//! Backed by a bundled SQLite database (`yt-offline.db`). Access goes through
|
||||||
|
//! a small `r2d2`-managed pool of connections rather than a single shared
|
||||||
|
//! `Connection` — that way concurrent read queries from different axum
|
||||||
|
//! handlers don't serialize on a mutex, and write queries still take their
|
||||||
|
//! turn via SQLite's own per-connection locking.
|
||||||
//!
|
//!
|
||||||
//! # Schema
|
//! # Schema
|
||||||
//!
|
//!
|
||||||
|
|
@ -10,13 +14,25 @@
|
||||||
//! | `positions` | `video_id` (PK), `position_secs`, `updated_at` | Stores resume positions |
|
//! | `positions` | `video_id` (PK), `position_secs`, `updated_at` | Stores resume positions |
|
||||||
//! | `settings` | `key` (PK), `value` | Persistent app settings (password hash, etc.) |
|
//! | `settings` | `key` (PK), `value` | Persistent app settings (password hash, etc.) |
|
||||||
|
|
||||||
|
use r2d2_sqlite::SqliteConnectionManager;
|
||||||
use rusqlite::{Connection, Result};
|
use rusqlite::{Connection, Result};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Thin wrapper around a SQLite connection with schema management.
|
type Pool = r2d2::Pool<SqliteConnectionManager>;
|
||||||
|
type PooledConn = r2d2::PooledConnection<SqliteConnectionManager>;
|
||||||
|
|
||||||
|
/// Default pool size for a file-backed database. Small intentionally — the
|
||||||
|
/// app is single-user and our queries are short. A handful is plenty.
|
||||||
|
const FILE_POOL_SIZE: u32 = 4;
|
||||||
|
|
||||||
|
/// Thin wrapper around an `r2d2` SQLite pool with schema management.
|
||||||
|
///
|
||||||
|
/// Construction always returns a pool with at least one usable connection
|
||||||
|
/// and the schema initialised. Subsequent method calls borrow a connection,
|
||||||
|
/// run their query, and return it — no external `Mutex` is needed.
|
||||||
pub struct Database {
|
pub struct Database {
|
||||||
conn: Connection,
|
pool: Pool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
|
|
@ -26,7 +42,12 @@ impl Database {
|
||||||
/// hash and resume positions aren't readable by other local users. A
|
/// hash and resume positions aren't readable by other local users. A
|
||||||
/// best-effort: failure is logged but doesn't abort startup.
|
/// best-effort: failure is logged but doesn't abort startup.
|
||||||
pub fn open(path: &Path) -> Result<Self> {
|
pub fn open(path: &Path) -> Result<Self> {
|
||||||
let conn = Connection::open(path)?;
|
let manager = SqliteConnectionManager::file(path);
|
||||||
|
let pool = Pool::builder()
|
||||||
|
.max_size(FILE_POOL_SIZE)
|
||||||
|
.build(manager)
|
||||||
|
.map_err(pool_init_to_rusqlite)?;
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
@ -38,21 +59,38 @@ impl Database {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let db = Database { conn };
|
|
||||||
|
let db = Database { pool };
|
||||||
db.init_schema()?;
|
db.init_schema()?;
|
||||||
Ok(db)
|
Ok(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open an in-memory database — used in tests.
|
/// Open an in-memory database — used in tests and as a fallback when the
|
||||||
|
/// real file can't be opened.
|
||||||
|
///
|
||||||
|
/// In-memory SQLite databases are per-connection by default, so the pool
|
||||||
|
/// is capped at 1 connection here. Otherwise each `get()` would hand back
|
||||||
|
/// a fresh, empty database and our schema/data would vanish between calls.
|
||||||
pub fn open_in_memory() -> Result<Self> {
|
pub fn open_in_memory() -> Result<Self> {
|
||||||
let conn = Connection::open_in_memory()?;
|
let manager = SqliteConnectionManager::memory();
|
||||||
let db = Database { conn };
|
let pool = Pool::builder()
|
||||||
|
.max_size(1)
|
||||||
|
.build(manager)
|
||||||
|
.map_err(pool_init_to_rusqlite)?;
|
||||||
|
let db = Database { pool };
|
||||||
db.init_schema()?;
|
db.init_schema()?;
|
||||||
Ok(db)
|
Ok(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Acquire a connection from the pool. Panics on pool failure — these
|
||||||
|
/// are effectively unrecoverable (the SQLite file vanished, the disk is
|
||||||
|
/// full / read-only, or the pool is exhausted under runaway load).
|
||||||
|
fn conn(&self) -> PooledConn {
|
||||||
|
self.pool.get().expect("db pool checkout failed")
|
||||||
|
}
|
||||||
|
|
||||||
fn init_schema(&self) -> Result<()> {
|
fn init_schema(&self) -> Result<()> {
|
||||||
self.conn.execute_batch(
|
self.conn().execute_batch(
|
||||||
"CREATE TABLE IF NOT EXISTS watched (
|
"CREATE TABLE IF NOT EXISTS watched (
|
||||||
video_id TEXT PRIMARY KEY,
|
video_id TEXT PRIMARY KEY,
|
||||||
watched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
watched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
|
@ -71,49 +109,54 @@ impl Database {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
|
pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
|
||||||
let mut stmt = self.conn.prepare("SELECT value FROM settings WHERE key = ?1")?;
|
let conn = self.conn();
|
||||||
|
let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?1")?;
|
||||||
let mut rows = stmt.query([key])?;
|
let mut rows = stmt.query([key])?;
|
||||||
Ok(rows.next()?.map(|r| r.get(0)).transpose()?)
|
Ok(rows.next()?.map(|r| r.get(0)).transpose()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_setting(&self, key: &str, value: Option<&str>) -> Result<()> {
|
pub fn set_setting(&self, key: &str, value: Option<&str>) -> Result<()> {
|
||||||
|
let conn = self.conn();
|
||||||
match value {
|
match value {
|
||||||
Some(v) => {
|
Some(v) => {
|
||||||
self.conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||||
[key, v],
|
[key, v],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
self.conn.execute("DELETE FROM settings WHERE key = ?1", [key])?;
|
conn.execute("DELETE FROM settings WHERE key = ?1", [key])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_watched(&self, video_id: &str, watched: bool) -> Result<()> {
|
pub fn set_watched(&self, video_id: &str, watched: bool) -> Result<()> {
|
||||||
|
let conn = self.conn();
|
||||||
if watched {
|
if watched {
|
||||||
self.conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO watched (video_id) VALUES (?1)",
|
"INSERT OR REPLACE INTO watched (video_id) VALUES (?1)",
|
||||||
[video_id],
|
[video_id],
|
||||||
)?;
|
)?;
|
||||||
} else {
|
} else {
|
||||||
self.conn.execute("DELETE FROM watched WHERE video_id = ?1", [video_id])?;
|
conn.execute("DELETE FROM watched WHERE video_id = ?1", [video_id])?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_watched(&self) -> Result<HashSet<String>> {
|
pub fn get_watched(&self) -> Result<HashSet<String>> {
|
||||||
let mut stmt = self.conn.prepare("SELECT video_id FROM watched")?;
|
let conn = self.conn();
|
||||||
|
let mut stmt = conn.prepare("SELECT video_id FROM watched")?;
|
||||||
let ids = stmt
|
let ids = stmt
|
||||||
.query_map([], |row| row.get(0))?
|
.query_map([], |row| row.get(0))?
|
||||||
.filter_map(Result::ok)
|
.filter_map(std::result::Result::ok)
|
||||||
.collect();
|
.collect();
|
||||||
Ok(ids)
|
Ok(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_position(&self, video_id: &str, position_secs: f64) -> Result<()> {
|
pub fn set_position(&self, video_id: &str, position_secs: f64) -> Result<()> {
|
||||||
self.conn.execute(
|
let conn = self.conn();
|
||||||
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO positions (video_id, position_secs, updated_at)
|
"INSERT OR REPLACE INTO positions (video_id, position_secs, updated_at)
|
||||||
VALUES (?1, ?2, CURRENT_TIMESTAMP)",
|
VALUES (?1, ?2, CURRENT_TIMESTAMP)",
|
||||||
rusqlite::params![video_id, position_secs],
|
rusqlite::params![video_id, position_secs],
|
||||||
|
|
@ -122,16 +165,34 @@ impl Database {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_position(&self, video_id: &str) -> Result<()> {
|
pub fn clear_position(&self, video_id: &str) -> Result<()> {
|
||||||
self.conn.execute("DELETE FROM positions WHERE video_id = ?1", [video_id])?;
|
let conn = self.conn();
|
||||||
|
conn.execute("DELETE FROM positions WHERE video_id = ?1", [video_id])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_positions(&self) -> Result<HashMap<String, f64>> {
|
pub fn get_positions(&self) -> Result<HashMap<String, f64>> {
|
||||||
let mut stmt = self.conn.prepare("SELECT video_id, position_secs FROM positions")?;
|
let conn = self.conn();
|
||||||
|
let mut stmt = conn.prepare("SELECT video_id, position_secs FROM positions")?;
|
||||||
let map = stmt
|
let map = stmt
|
||||||
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?)))?
|
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?)))?
|
||||||
.filter_map(Result::ok)
|
.filter_map(std::result::Result::ok)
|
||||||
.collect();
|
.collect();
|
||||||
Ok(map)
|
Ok(map)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Translate an `r2d2::Error` from `Pool::build()` into a `rusqlite::Error` so
|
||||||
|
/// callers don't have to juggle two error types. Pool-init failures are rare
|
||||||
|
/// (bad file path, OS-level problem) and the surfaced error message is what
|
||||||
|
/// matters; the variant is incidental.
|
||||||
|
fn pool_init_to_rusqlite(e: r2d2::Error) -> rusqlite::Error {
|
||||||
|
rusqlite::Error::ToSqlConversionFailure(Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
format!("sqlite pool init failed: {e}"),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// `Connection` is still imported for the type alias path; suppress the
|
||||||
|
// unused-import warning when no caller references it directly.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
type _SilenceConnectionImport = Connection;
|
||||||
|
|
|
||||||
|
|
@ -230,9 +230,11 @@ fn classify_tiktok(url: &str) -> UrlKind {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn classify_twitch(url: &str) -> UrlKind {
|
fn classify_twitch(url: &str) -> UrlKind {
|
||||||
// twitch.tv/<user> → Channel
|
// twitch.tv/<user> → Channel (bare profile)
|
||||||
|
// twitch.tv/<user>/clips → Channel (clips-only listing)
|
||||||
|
// twitch.tv/<user>/videos → Channel (VOD listing)
|
||||||
// twitch.tv/videos/<id> → Video (VOD)
|
// twitch.tv/videos/<id> → Video (VOD)
|
||||||
// twitch.tv/<user>/clip/<id> → Video (clip)
|
// twitch.tv/<user>/clip/<id> → Video (single clip)
|
||||||
if let Some(rest) = url.split("twitch.tv/").nth(1) {
|
if let Some(rest) = url.split("twitch.tv/").nth(1) {
|
||||||
let first = rest.split('/').next().unwrap_or("").trim_end_matches('?');
|
let first = rest.split('/').next().unwrap_or("").trim_end_matches('?');
|
||||||
if first.is_empty() { return UrlKind::Unknown; }
|
if first.is_empty() { return UrlKind::Unknown; }
|
||||||
|
|
@ -240,10 +242,16 @@ fn classify_twitch(url: &str) -> UrlKind {
|
||||||
if rest.contains("/clip/") || rest.contains("/video/") {
|
if rest.contains("/clip/") || rest.contains("/video/") {
|
||||||
return UrlKind::Video;
|
return UrlKind::Video;
|
||||||
}
|
}
|
||||||
// No nested path means a bare channel page.
|
|
||||||
let nested = rest.trim_start_matches(first).trim_start_matches('/');
|
let nested = rest.trim_start_matches(first).trim_start_matches('/');
|
||||||
let nested = nested.split('?').next().unwrap_or("");
|
let nested = nested.split('?').next().unwrap_or("");
|
||||||
if nested.is_empty() || nested == "videos" || nested == "about" {
|
// Bare channel + channel-scoped listing pages all collapse to Channel.
|
||||||
|
// yt-dlp's extractors recognise each variant and pull the right
|
||||||
|
// subset (clips/highlights/uploads/all) without further hints.
|
||||||
|
if nested.is_empty()
|
||||||
|
|| nested == "videos"
|
||||||
|
|| nested == "about"
|
||||||
|
|| nested == "clips"
|
||||||
|
{
|
||||||
return UrlKind::Channel { handle: first.to_string() };
|
return UrlKind::Channel { handle: first.to_string() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -423,6 +431,15 @@ mod tests {
|
||||||
assert!(matches!(info.kind, UrlKind::Video));
|
assert!(matches!(info.kind, UrlKind::Video));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn twitch_clips_listing_is_channel() {
|
||||||
|
let info = classify_url("https://www.twitch.tv/streamername/clips");
|
||||||
|
match info.kind {
|
||||||
|
UrlKind::Channel { handle } => assert_eq!(handle, "streamername"),
|
||||||
|
_ => panic!("expected Channel with clips listing"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn vimeo_numeric_is_video() {
|
fn vimeo_numeric_is_video() {
|
||||||
let info = classify_url("https://vimeo.com/123456");
|
let info = classify_url("https://vimeo.com/123456");
|
||||||
|
|
|
||||||
1144
src/web.rs
1144
src/web.rs
File diff suppressed because it is too large
Load diff
1107
src/web_ui/index.html
Normal file
1107
src/web_ui/index.html
Normal file
File diff suppressed because it is too large
Load diff
36
src/web_ui/login.html
Normal file
36
src/web_ui/login.html
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>yt-offline — Sign in</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{background:#1a1a2e;color:#eee;font:14px/1.5 system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh}
|
||||||
|
.box{background:#16213e;border:1px solid #334;border-radius:8px;padding:28px;width:300px;display:flex;flex-direction:column;gap:12px}
|
||||||
|
h1{font-size:1.1em;text-align:center}
|
||||||
|
input{background:#0f3460;border:1px solid #334;color:#eee;padding:9px 11px;border-radius:4px;font-size:14px}
|
||||||
|
button{background:#e94560;border:none;color:#fff;padding:9px;border-radius:4px;cursor:pointer;font-size:14px;font-weight:600}
|
||||||
|
.err{color:#f87171;font-size:12px;text-align:center;min-height:16px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="box">
|
||||||
|
<h1>yt-offline</h1>
|
||||||
|
<input type="password" id="pwd" placeholder="Password" autofocus onkeydown="if(event.key==='Enter')login()">
|
||||||
|
<button onclick="login()">Sign in</button>
|
||||||
|
<div class="err" id="err"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
async function login(){
|
||||||
|
const pwd=document.getElementById('pwd').value;
|
||||||
|
const err=document.getElementById('err');
|
||||||
|
err.textContent='';
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pwd})});
|
||||||
|
if(r.ok){location.reload()}else{err.textContent='Invalid password'}
|
||||||
|
}catch{err.textContent='Connection error'}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue