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 = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
|
|
@ -268,6 +268,18 @@ dependencies = [
|
|||
"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]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
|
|
@ -732,6 +744,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
|
|
@ -792,6 +815,23 @@ dependencies = [
|
|||
"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]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
|
|
@ -860,6 +900,15 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
|
|
@ -1395,6 +1444,7 @@ dependencies = [
|
|||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
|
@ -2873,6 +2923,28 @@ version = "6.0.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
|
|
@ -2894,6 +2966,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
|
|
@ -2932,6 +3015,12 @@ dependencies = [
|
|||
"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]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
|
|
@ -3077,6 +3166,15 @@ dependencies = [
|
|||
"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]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
|
|
@ -3201,7 +3299,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
|
|
@ -3685,8 +3783,10 @@ version = "0.5.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.11.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
|
|
@ -3841,7 +3941,9 @@ version = "1.23.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
"rand 0.10.1",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
|
@ -4939,6 +5041,8 @@ dependencies = [
|
|||
"eframe",
|
||||
"image",
|
||||
"notify-rust",
|
||||
"r2d2",
|
||||
"r2d2_sqlite",
|
||||
"rand 0.8.6",
|
||||
"rfd",
|
||||
"rusqlite",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ toml = "0.8"
|
|||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
r2d2 = "0.8"
|
||||
r2d2_sqlite = "0.24"
|
||||
notify-rust = { version = "4", default-features = false, features = ["z"] }
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
|
|
|||
54
src/app.rs
54
src/app.rs
|
|
@ -88,6 +88,10 @@ pub struct App {
|
|||
dl_music_mode: bool,
|
||||
/// Record an ongoing live stream from the start instead of joining live.
|
||||
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>>,
|
||||
thumb_request_tx: Sender<PathBuf>,
|
||||
thumb_result_rx: Receiver<(PathBuf, Option<egui::ColorImage>)>,
|
||||
|
|
@ -225,6 +229,7 @@ impl App {
|
|||
dl_quality: DownloadQuality::Best,
|
||||
dl_music_mode: false,
|
||||
dl_live: false,
|
||||
dl_twitch_clips: false,
|
||||
textures: HashMap::new(),
|
||||
thumb_request_tx,
|
||||
thumb_result_rx,
|
||||
|
|
@ -947,11 +952,32 @@ impl App {
|
|||
so yt-dlp records from the beginning instead of joining mid-stream. \
|
||||
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();
|
||||
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();
|
||||
if self.dl_music_mode {
|
||||
self.downloader.start_music(url);
|
||||
|
|
@ -1708,6 +1734,16 @@ impl App {
|
|||
});
|
||||
|
||||
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 {
|
||||
ui.label(egui::RichText::new(format_duration(secs)).small().weak());
|
||||
ui.label(egui::RichText::new("·").weak());
|
||||
|
|
@ -2076,6 +2112,12 @@ impl App {
|
|||
ui.label(egui::RichText::new("·").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 {
|
||||
ui.label(egui::RichText::new("·").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}") }
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
if bytes >= 1_073_741_824 {
|
||||
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
|
||||
//!
|
||||
|
|
@ -10,13 +14,25 @@
|
|||
//! | `positions` | `video_id` (PK), `position_secs`, `updated_at` | Stores resume positions |
|
||||
//! | `settings` | `key` (PK), `value` | Persistent app settings (password hash, etc.) |
|
||||
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use rusqlite::{Connection, Result};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
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 {
|
||||
conn: Connection,
|
||||
pool: Pool,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
|
|
@ -26,7 +42,12 @@ impl Database {
|
|||
/// hash and resume positions aren't readable by other local users. A
|
||||
/// best-effort: failure is logged but doesn't abort startup.
|
||||
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)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
|
@ -38,21 +59,38 @@ impl Database {
|
|||
}
|
||||
}
|
||||
}
|
||||
let db = Database { conn };
|
||||
|
||||
let db = Database { pool };
|
||||
db.init_schema()?;
|
||||
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> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
let db = Database { conn };
|
||||
let manager = SqliteConnectionManager::memory();
|
||||
let pool = Pool::builder()
|
||||
.max_size(1)
|
||||
.build(manager)
|
||||
.map_err(pool_init_to_rusqlite)?;
|
||||
let db = Database { pool };
|
||||
db.init_schema()?;
|
||||
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<()> {
|
||||
self.conn.execute_batch(
|
||||
self.conn().execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS watched (
|
||||
video_id TEXT PRIMARY KEY,
|
||||
watched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
|
|
@ -71,49 +109,54 @@ impl Database {
|
|||
}
|
||||
|
||||
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])?;
|
||||
Ok(rows.next()?.map(|r| r.get(0)).transpose()?)
|
||||
}
|
||||
|
||||
pub fn set_setting(&self, key: &str, value: Option<&str>) -> Result<()> {
|
||||
let conn = self.conn();
|
||||
match value {
|
||||
Some(v) => {
|
||||
self.conn.execute(
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
[key, v],
|
||||
)?;
|
||||
}
|
||||
None => {
|
||||
self.conn.execute("DELETE FROM settings WHERE key = ?1", [key])?;
|
||||
conn.execute("DELETE FROM settings WHERE key = ?1", [key])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_watched(&self, video_id: &str, watched: bool) -> Result<()> {
|
||||
let conn = self.conn();
|
||||
if watched {
|
||||
self.conn.execute(
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO watched (video_id) VALUES (?1)",
|
||||
[video_id],
|
||||
)?;
|
||||
} 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(())
|
||||
}
|
||||
|
||||
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
|
||||
.query_map([], |row| row.get(0))?
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(std::result::Result::ok)
|
||||
.collect();
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
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)
|
||||
VALUES (?1, ?2, CURRENT_TIMESTAMP)",
|
||||
rusqlite::params![video_id, position_secs],
|
||||
|
|
@ -122,16 +165,34 @@ impl Database {
|
|||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
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
|
||||
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?)))?
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(std::result::Result::ok)
|
||||
.collect();
|
||||
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 {
|
||||
// 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/<user>/clip/<id> → Video (clip)
|
||||
// twitch.tv/<user>/clip/<id> → Video (single clip)
|
||||
if let Some(rest) = url.split("twitch.tv/").nth(1) {
|
||||
let first = rest.split('/').next().unwrap_or("").trim_end_matches('?');
|
||||
if first.is_empty() { return UrlKind::Unknown; }
|
||||
|
|
@ -240,10 +242,16 @@ fn classify_twitch(url: &str) -> UrlKind {
|
|||
if rest.contains("/clip/") || rest.contains("/video/") {
|
||||
return UrlKind::Video;
|
||||
}
|
||||
// No nested path means a bare channel page.
|
||||
let nested = rest.trim_start_matches(first).trim_start_matches('/');
|
||||
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() };
|
||||
}
|
||||
}
|
||||
|
|
@ -423,6 +431,15 @@ mod tests {
|
|||
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]
|
||||
fn vimeo_numeric_is_video() {
|
||||
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