diff --git a/Cargo.lock b/Cargo.lock index 8b64056..f242843 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 3b046a4..3edf2a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/src/app.rs b/src/app.rs index 8ef0ef6..b4dfb7e 100644 --- a/src/app.rs +++ b/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>, thumb_request_tx: Sender, thumb_result_rx: Receiver<(PathBuf, Option)>, @@ -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//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/` to + // `twitch.tv//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) diff --git a/src/database.rs b/src/database.rs index a2619e5..b21acec 100644 --- a/src/database.rs +++ b/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; +type PooledConn = r2d2::PooledConnection; + +/// 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 { - 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 { - 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> { - 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> { - 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> { - 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; diff --git a/src/platform.rs b/src/platform.rs index 30f4675..1a76828 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -230,9 +230,11 @@ fn classify_tiktok(url: &str) -> UrlKind { } fn classify_twitch(url: &str) -> UrlKind { - // twitch.tv/ β†’ Channel + // twitch.tv/ β†’ Channel (bare profile) + // twitch.tv//clips β†’ Channel (clips-only listing) + // twitch.tv//videos β†’ Channel (VOD listing) // twitch.tv/videos/ β†’ Video (VOD) - // twitch.tv//clip/ β†’ Video (clip) + // twitch.tv//clip/ β†’ 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"); diff --git a/src/web.rs b/src/web.rs index 65774b5..70a8f17 100644 --- a/src/web.rs +++ b/src/web.rs @@ -73,8 +73,10 @@ pub struct WebState { pub watched: Mutex>, /// Last known playback position per video ID in seconds (persisted in SQLite). pub positions: Mutex>, - /// Shared SQLite connection, opened once at startup instead of per request. - pub db: Mutex, + /// SQLite handle. Internally backed by an `r2d2` pool, so concurrent + /// handlers each check out their own connection without serializing on + /// an external mutex. + pub db: Database, /// YouTube channels directory (the legacy `channels/` folder, kept for /// backward compat). Other platforms live as siblings under [`library_root`]. pub channels_root: PathBuf, @@ -680,7 +682,7 @@ fn bump_library_version(state: &WebState) { /// Re-read the password setting from the DB and update the cache. Called /// after any change that could affect whether a password exists. fn refresh_password_cache(state: &WebState) { - let present = state.db.lock().unwrap() + let present = state.db .get_setting("password_hash").ok().flatten().is_some(); state.password_required_cache.store(present, Ordering::Relaxed); } @@ -732,7 +734,7 @@ async fn post_login( } } - let hash = state.db.lock().unwrap().get_setting("password_hash").ok().flatten(); + let hash = state.db.get_setting("password_hash").ok().flatten(); let Some(hash) = hash else { // No password configured; nothing to authenticate against. return (StatusCode::OK, "no password set").into_response(); @@ -1013,7 +1015,7 @@ async fn post_watched( State(state): State>, Path(video_id): Path, ) -> impl IntoResponse { - let db = state.db.lock().unwrap(); + let db = &state.db; let mut watched = state.watched.lock().unwrap(); let now_watched = !watched.contains(&video_id); if let Err(e) = db.set_watched(&video_id, now_watched) { @@ -1122,7 +1124,7 @@ async fn post_settings( } if let Some(new_pwd) = &body.new_download_password { - let db = state.db.lock().unwrap(); + let db = &state.db; let result = if new_pwd.is_empty() { db.set_setting("password_hash", None) } else if let Some(hashed) = hash_password(new_pwd) { @@ -1130,7 +1132,6 @@ async fn post_settings( } else { return (StatusCode::INTERNAL_SERVER_ERROR, "failed to hash password").into_response(); }; - drop(db); if let Err(e) = result { return (StatusCode::INTERNAL_SERVER_ERROR, format!("db error: {e}")).into_response(); } @@ -1279,7 +1280,7 @@ async fn post_resume( // ETag optimisation. let mut positions = state.positions.lock().unwrap(); let was_resumable = positions.get(&video_id).copied().is_some_and(|p| p > 3.0); - let db = state.db.lock().unwrap(); + let db = &state.db; if body.position > 3.0 { let _ = db.set_position(&video_id, body.position); positions.insert(video_id.clone(), body.position); @@ -1287,7 +1288,6 @@ async fn post_resume( let _ = db.clear_position(&video_id); positions.remove(&video_id); } - drop(db); drop(positions); let now_resumable = body.position > 3.0; if was_resumable != now_resumable { @@ -1454,7 +1454,7 @@ async fn get_description( async fn post_rescan(State(state): State>) -> impl IntoResponse { let new_lib = library::scan_channels(&state.channels_root); // Refresh watched from DB after rescan - if let Ok(w) = state.db.lock().unwrap().get_watched() { + if let Ok(w) = state.db.get_watched() { *state.watched.lock().unwrap() = w; } *state.library.lock().unwrap() = new_lib; @@ -1671,7 +1671,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { downloader: Mutex::new(downloader), watched: Mutex::new(watched), positions: Mutex::new(positions), - db: Mutex::new(db), + db, channels_root: channels_root.clone(), library_root: library_root.clone(), config_path, @@ -1789,1120 +1789,18 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { } } + // ── Embedded UI ─────────────────────────────────────────────────────────────── +// +// The HTML/CSS/JS lives in standalone files under `src/web_ui/` so editors +// give it syntax highlighting and the diff churn for UI tweaks stays out of +// this Rust source. `include_str!` bakes them into the binary at compile +// time, so there's no runtime fs lookup and no extra deploy artifacts. /// Standalone login page served at `GET /` when a password is set and the /// request is unauthenticated. Posts to `/api/login` and reloads on success. -const LOGIN_HTML: &str = r#" - - - -yt-offline β€” Sign in - - - -
-

yt-offline

- - -
-
- - -"#; +const LOGIN_HTML: &str = include_str!("web_ui/login.html"); -const HTML_UI: &str = r#" - - - -yt-offline - - - -
- -

yt-offline

- - - - - - - - -
- -
- -
-
- - -
-
-
-
-
-
-
- - - - - - -
- - - -"#; +/// Main library UI. Single-page app with embedded styles and JS β€” see the +/// architecture notes at the top of `web_ui/index.html`. +const HTML_UI: &str = include_str!("web_ui/index.html"); diff --git a/src/web_ui/index.html b/src/web_ui/index.html new file mode 100644 index 0000000..34cdd38 --- /dev/null +++ b/src/web_ui/index.html @@ -0,0 +1,1107 @@ + + + + +yt-offline + + + +
+ +

yt-offline

+ + + + + + + + +
+ +
+ +
+
+ + +
+
+
+
+
+
+
+ + + + + + + +
+ + + + diff --git a/src/web_ui/login.html b/src/web_ui/login.html new file mode 100644 index 0000000..56f0dcf --- /dev/null +++ b/src/web_ui/login.html @@ -0,0 +1,36 @@ + + + + +yt-offline β€” Sign in + + + +
+

yt-offline

+ + +
+
+ + +