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:
Luna 2026-05-25 01:17:08 -07:00
parent 8d1c274075
commit 375a24262c
8 changed files with 1428 additions and 1151 deletions

View file

@ -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)

View file

@ -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;

View file

@ -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

File diff suppressed because it is too large Load diff

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
View 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>