Fix web UI, sort labels, add channel right-click menu
Web UI: - Replace SSE with simple 600ms polling (GET /api/progress) — SSE added complexity with no real benefit for a local tool and was the source of connection failures - Poll task moved into request handler (dl.poll() on each /api/progress call) so no background goroutine needed; simplifies state greatly - Remove tokio-stream dependency (no longer needed) - Fix: create channels dir before opening DB in web serve() - Rescan also refreshes watched set from DB - Bulk watched, sort, channel download button all working in web UI Sort labels: - Rename "Dur ↑" / "Dur ↓" → "Shortest" / "Longest" - Rename "Size ↑" / "Size ↓" → "Smallest" / "Largest" Channel right-click context menu: - Right-click any channel in sidebar → "Check for new videos" starts a yt-dlp job on the stored channel_url from its info.json - Also shows "Open folder" shortcut - Grayed out with tooltip if no URL is stored yet (channel has no info.json) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
56f1f1bd80
commit
8b4796b518
4 changed files with 303 additions and 274 deletions
26
Cargo.lock
generated
26
Cargo.lock
generated
|
|
@ -3973,31 +3973,6 @@ dependencies = [
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-stream"
|
|
||||||
version = "0.1.18"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
|
||||||
dependencies = [
|
|
||||||
"futures-core",
|
|
||||||
"pin-project-lite",
|
|
||||||
"tokio",
|
|
||||||
"tokio-util",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-util"
|
|
||||||
version = "0.7.18"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"futures-core",
|
|
||||||
"futures-sink",
|
|
||||||
"pin-project-lite",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
|
|
@ -5365,7 +5340,6 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
|
||||||
"toml",
|
"toml",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tray-icon",
|
"tray-icon",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ tray-icon = "0.13"
|
||||||
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"] }
|
||||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
|
||||||
tower-http = { version = "0.5", features = ["cors"] }
|
tower-http = { version = "0.5", features = ["cors"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|
|
||||||
49
src/app.rs
49
src/app.rs
|
|
@ -517,14 +517,18 @@ impl App {
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
|
// Collect any right-click download action outside the loop
|
||||||
|
let mut pending_ch_download: Option<(String, String)> = None; // (url, channel_name)
|
||||||
|
|
||||||
for i in 0..self.library.len() {
|
for i in 0..self.library.len() {
|
||||||
let (name, total, has_playlists, size_bytes) = {
|
let (name, total, has_playlists, size_bytes, channel_url) = {
|
||||||
let ch = &self.library[i];
|
let ch = &self.library[i];
|
||||||
(
|
(
|
||||||
ch.name.clone(),
|
ch.name.clone(),
|
||||||
ch.total_videos(),
|
ch.total_videos(),
|
||||||
!ch.playlists.is_empty(),
|
!ch.playlists.is_empty(),
|
||||||
Self::channel_total_size(ch),
|
Self::channel_total_size(ch),
|
||||||
|
ch.meta.as_ref().and_then(|m| m.channel_url.clone()),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -539,14 +543,34 @@ impl App {
|
||||||
let label = format!("{} ({}{})", name, total, size_str);
|
let label = format!("{} ({}{})", name, total, size_str);
|
||||||
|
|
||||||
let ch_selected_no_pl = matches!(self.sidebar_view, SidebarView::Channel(ci) if ci == i);
|
let ch_selected_no_pl = matches!(self.sidebar_view, SidebarView::Channel(ci) if ci == i);
|
||||||
if ui
|
let resp = ui
|
||||||
.selectable_label(ch_selected_no_pl, label)
|
.selectable_label(ch_selected_no_pl, label)
|
||||||
.on_hover_text(self.library[i].path.display().to_string())
|
.on_hover_text(self.library[i].path.display().to_string());
|
||||||
.clicked()
|
if resp.clicked() {
|
||||||
{
|
|
||||||
self.sidebar_view = SidebarView::Channel(i);
|
self.sidebar_view = SidebarView::Channel(i);
|
||||||
self.selected_video = None;
|
self.selected_video = None;
|
||||||
}
|
}
|
||||||
|
// Right-click context menu
|
||||||
|
let url_for_menu = channel_url.clone();
|
||||||
|
let name_for_menu = name.clone();
|
||||||
|
resp.context_menu(|ui| {
|
||||||
|
if let Some(ref url) = url_for_menu {
|
||||||
|
if ui.button("⬇ Check for new videos").clicked() {
|
||||||
|
pending_ch_download = Some((url.clone(), name_for_menu.clone()));
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ui.add_enabled(false, egui::Button::new("⬇ Check for new videos"))
|
||||||
|
.on_hover_text("Download this channel first to store its URL");
|
||||||
|
}
|
||||||
|
if ui.button("📁 Open folder").clicked() {
|
||||||
|
let path = self.library[i].path.clone();
|
||||||
|
if let Err(e) = std::process::Command::new("xdg-open").arg(&path).spawn() {
|
||||||
|
eprintln!("xdg-open: {e}");
|
||||||
|
}
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if is_ch_selected && has_playlists {
|
if is_ch_selected && has_playlists {
|
||||||
let playlist_count = self.library[i].playlists.len();
|
let playlist_count = self.library[i].playlists.len();
|
||||||
|
|
@ -564,6 +588,13 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process deferred right-click download action
|
||||||
|
if let Some((url, ch_name)) = pending_ch_download {
|
||||||
|
let kind = detect_url_kind(&url);
|
||||||
|
self.downloader.start(url, &kind);
|
||||||
|
self.status = format!("Checking {} for new videos…", ch_name);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -946,10 +977,10 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
ui.selectable_value(&mut self.sort_mode, SortMode::SizeDesc, "Size ↓");
|
ui.selectable_value(&mut self.sort_mode, SortMode::SizeDesc, "Largest");
|
||||||
ui.selectable_value(&mut self.sort_mode, SortMode::SizeAsc, "Size ↑");
|
ui.selectable_value(&mut self.sort_mode, SortMode::SizeAsc, "Smallest");
|
||||||
ui.selectable_value(&mut self.sort_mode, SortMode::DurationDesc, "Dur ↓");
|
ui.selectable_value(&mut self.sort_mode, SortMode::DurationDesc, "Longest");
|
||||||
ui.selectable_value(&mut self.sort_mode, SortMode::DurationAsc, "Dur ↑");
|
ui.selectable_value(&mut self.sort_mode, SortMode::DurationAsc, "Shortest");
|
||||||
ui.selectable_value(&mut self.sort_mode, SortMode::Title, "Title");
|
ui.selectable_value(&mut self.sort_mode, SortMode::Title, "Title");
|
||||||
ui.label(egui::RichText::new("Sort:").weak());
|
ui.label(egui::RichText::new("Sort:").weak());
|
||||||
});
|
});
|
||||||
|
|
|
||||||
457
src/web.rs
457
src/web.rs
|
|
@ -1,8 +1,4 @@
|
||||||
//! Web interface — run with `--web [PORT]` instead of the GUI.
|
//! Web interface — run with `--web [PORT]` instead of the GUI.
|
||||||
//!
|
|
||||||
//! Serves a browser-based UI on http://localhost:PORT that mirrors the
|
|
||||||
//! desktop app's core features: browse library, start downloads, track
|
|
||||||
//! progress, toggle watched.
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
@ -11,21 +7,18 @@ use std::sync::{Arc, Mutex};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::{header, StatusCode},
|
http::{header, StatusCode},
|
||||||
response::{IntoResponse, Sse},
|
response::IntoResponse,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::broadcast;
|
|
||||||
use tokio_stream::wrappers::BroadcastStream;
|
|
||||||
use tokio_stream::StreamExt as _;
|
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use crate::downloader::{detect_url_kind, Downloader, JobState};
|
use crate::downloader::{detect_url_kind, Downloader, JobState};
|
||||||
use crate::library;
|
use crate::library;
|
||||||
|
|
||||||
// ── Shared state ─────────────────────────────────────────────────────────────
|
// ── Shared state ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
pub struct JobSnapshot {
|
pub struct JobSnapshot {
|
||||||
|
|
@ -42,13 +35,10 @@ pub struct WebState {
|
||||||
pub watched: Mutex<HashSet<String>>,
|
pub watched: Mutex<HashSet<String>>,
|
||||||
pub db_path: PathBuf,
|
pub db_path: PathBuf,
|
||||||
pub channels_root: PathBuf,
|
pub channels_root: PathBuf,
|
||||||
pub browser: String,
|
|
||||||
/// Broadcast channel for SSE progress events
|
|
||||||
pub progress_tx: broadcast::Sender<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebState {
|
impl WebState {
|
||||||
pub fn job_snapshots(dl: &Downloader) -> Vec<JobSnapshot> {
|
fn job_snapshots(dl: &Downloader) -> Vec<JobSnapshot> {
|
||||||
dl.jobs
|
dl.jobs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|j| JobSnapshot {
|
.map(|j| JobSnapshot {
|
||||||
|
|
@ -112,7 +102,7 @@ struct ProgressResponse {
|
||||||
jobs: Vec<JobSnapshot>,
|
jobs: Vec<JobSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Route handlers ────────────────────────────────────────────────────────────
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn get_index() -> impl IntoResponse {
|
async fn get_index() -> impl IntoResponse {
|
||||||
(
|
(
|
||||||
|
|
@ -125,17 +115,7 @@ async fn get_library(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||||
let lib = state.library.lock().unwrap();
|
let lib = state.library.lock().unwrap();
|
||||||
let watched = state.watched.lock().unwrap();
|
let watched = state.watched.lock().unwrap();
|
||||||
|
|
||||||
let channels = lib
|
let to_info = |v: &library::Video, watched: &HashSet<String>| VideoInfo {
|
||||||
.iter()
|
|
||||||
.map(|ch| {
|
|
||||||
let size_bytes: u64 = ch
|
|
||||||
.videos
|
|
||||||
.iter()
|
|
||||||
.chain(ch.playlists.iter().flat_map(|p| p.videos.iter()))
|
|
||||||
.filter_map(|v| v.file_size)
|
|
||||||
.sum();
|
|
||||||
|
|
||||||
let to_info = |v: &library::Video| VideoInfo {
|
|
||||||
id: v.id.clone(),
|
id: v.id.clone(),
|
||||||
title: v.title.clone(),
|
title: v.title.clone(),
|
||||||
duration_secs: v.duration_secs,
|
duration_secs: v.duration_secs,
|
||||||
|
|
@ -145,6 +125,11 @@ async fn get_library(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||||
watched: watched.contains(&v.id),
|
watched: watched.contains(&v.id),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let channels = lib.iter().map(|ch| {
|
||||||
|
let size_bytes: u64 = ch.videos.iter()
|
||||||
|
.chain(ch.playlists.iter().flat_map(|p| p.videos.iter()))
|
||||||
|
.filter_map(|v| v.file_size)
|
||||||
|
.sum();
|
||||||
ChannelInfo {
|
ChannelInfo {
|
||||||
name: ch.name.clone(),
|
name: ch.name.clone(),
|
||||||
total_videos: ch.total_videos(),
|
total_videos: ch.total_videos(),
|
||||||
|
|
@ -152,22 +137,23 @@ async fn get_library(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||||
subscriber_count: ch.meta.as_ref().and_then(|m| m.subscriber_count),
|
subscriber_count: ch.meta.as_ref().and_then(|m| m.subscriber_count),
|
||||||
uploader: ch.meta.as_ref().and_then(|m| m.uploader.clone()),
|
uploader: ch.meta.as_ref().and_then(|m| m.uploader.clone()),
|
||||||
channel_url: ch.meta.as_ref().and_then(|m| m.channel_url.clone()),
|
channel_url: ch.meta.as_ref().and_then(|m| m.channel_url.clone()),
|
||||||
playlists: ch
|
playlists: ch.playlists.iter().map(|p| PlaylistInfo {
|
||||||
.playlists
|
|
||||||
.iter()
|
|
||||||
.map(|p| PlaylistInfo {
|
|
||||||
name: p.name.clone(),
|
name: p.name.clone(),
|
||||||
videos: p.videos.iter().map(to_info).collect(),
|
videos: p.videos.iter().map(|v| to_info(v, &watched)).collect(),
|
||||||
})
|
}).collect(),
|
||||||
.collect(),
|
videos: ch.videos.iter().map(|v| to_info(v, &watched)).collect(),
|
||||||
videos: ch.videos.iter().map(to_info).collect(),
|
|
||||||
}
|
}
|
||||||
})
|
}).collect();
|
||||||
.collect();
|
|
||||||
|
|
||||||
Json(LibraryResponse { channels })
|
Json(LibraryResponse { channels })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_progress(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||||
|
let mut dl = state.downloader.lock().unwrap();
|
||||||
|
dl.poll();
|
||||||
|
Json(ProgressResponse { jobs: WebState::job_snapshots(&dl) })
|
||||||
|
}
|
||||||
|
|
||||||
async fn post_download(
|
async fn post_download(
|
||||||
State(state): State<Arc<WebState>>,
|
State(state): State<Arc<WebState>>,
|
||||||
Json(body): Json<StartDownloadRequest>,
|
Json(body): Json<StartDownloadRequest>,
|
||||||
|
|
@ -177,18 +163,10 @@ async fn post_download(
|
||||||
return (StatusCode::BAD_REQUEST, "empty URL").into_response();
|
return (StatusCode::BAD_REQUEST, "empty URL").into_response();
|
||||||
}
|
}
|
||||||
let kind = detect_url_kind(&url);
|
let kind = detect_url_kind(&url);
|
||||||
{
|
state.downloader.lock().unwrap().start(url, &kind);
|
||||||
let mut dl = state.downloader.lock().unwrap();
|
|
||||||
dl.start(url, &kind);
|
|
||||||
}
|
|
||||||
(StatusCode::ACCEPTED, "ok").into_response()
|
(StatusCode::ACCEPTED, "ok").into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_progress(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
|
||||||
let dl = state.downloader.lock().unwrap();
|
|
||||||
Json(ProgressResponse { jobs: WebState::job_snapshots(&dl) })
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_watched(
|
async fn post_watched(
|
||||||
State(state): State<Arc<WebState>>,
|
State(state): State<Arc<WebState>>,
|
||||||
Path(video_id): Path<String>,
|
Path(video_id): Path<String>,
|
||||||
|
|
@ -202,53 +180,41 @@ async fn post_watched(
|
||||||
if let Err(e) = db.set_watched(&video_id, now_watched) {
|
if let Err(e) = db.set_watched(&video_id, now_watched) {
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
|
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
|
||||||
}
|
}
|
||||||
if now_watched {
|
if now_watched { watched.insert(video_id); } else { watched.remove(&video_id); }
|
||||||
watched.insert(video_id);
|
|
||||||
} else {
|
|
||||||
watched.remove(&video_id);
|
|
||||||
}
|
|
||||||
(StatusCode::OK, if now_watched { "watched" } else { "unwatched" }).into_response()
|
(StatusCode::OK, if now_watched { "watched" } else { "unwatched" }).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_events(State(state): State<Arc<WebState>>) -> Sse<impl tokio_stream::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>> {
|
|
||||||
let rx = state.progress_tx.subscribe();
|
|
||||||
let stream = BroadcastStream::new(rx).filter_map(|msg| {
|
|
||||||
msg.ok().map(|data| {
|
|
||||||
Ok(axum::response::sse::Event::default().data(data))
|
|
||||||
})
|
|
||||||
});
|
|
||||||
Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_rescan(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
async fn post_rescan(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||||
let root = state.channels_root.clone();
|
let new_lib = library::scan_channels(&state.channels_root);
|
||||||
let new_lib = library::scan_channels(&root);
|
// Refresh watched from DB after rescan
|
||||||
|
if let Ok(db) = Database::open(&state.db_path) {
|
||||||
|
if let Ok(w) = db.get_watched() {
|
||||||
|
*state.watched.lock().unwrap() = w;
|
||||||
|
}
|
||||||
|
}
|
||||||
*state.library.lock().unwrap() = new_lib;
|
*state.library.lock().unwrap() = new_lib;
|
||||||
(StatusCode::OK, "rescanned")
|
(StatusCode::OK, "rescanned")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Server entry point ────────────────────────────────────────────────────────
|
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn run(config: Config) -> ! {
|
pub fn run(config: Config) -> ! {
|
||||||
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
||||||
rt.block_on(async move {
|
rt.block_on(serve(config));
|
||||||
serve(config).await;
|
|
||||||
});
|
|
||||||
unreachable!()
|
unreachable!()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve(config: Config) {
|
async fn serve(config: Config) {
|
||||||
let channels_root = config.backup.directory.clone();
|
let channels_root = config.backup.directory.clone();
|
||||||
|
let _ = std::fs::create_dir_all(&channels_root);
|
||||||
let db_path = channels_root.join("yt-offline.db");
|
let db_path = channels_root.join("yt-offline.db");
|
||||||
|
|
||||||
let library = library::scan_channels(&channels_root);
|
let library = library::scan_channels(&channels_root);
|
||||||
|
let watched = Database::open(&db_path)
|
||||||
|
.and_then(|db| db.get_watched())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let db = Database::open(&db_path).expect("web: open db");
|
let downloader = Downloader::new(channels_root.clone(), config.player.browser.clone());
|
||||||
let watched = db.get_watched().unwrap_or_default();
|
|
||||||
|
|
||||||
let browser = config.player.browser.clone();
|
|
||||||
let downloader = Downloader::new(channels_root.clone(), browser.clone());
|
|
||||||
|
|
||||||
let (progress_tx, _) = broadcast::channel::<String>(64);
|
|
||||||
|
|
||||||
let state = Arc::new(WebState {
|
let state = Arc::new(WebState {
|
||||||
library: Mutex::new(library),
|
library: Mutex::new(library),
|
||||||
|
|
@ -256,45 +222,26 @@ async fn serve(config: Config) {
|
||||||
watched: Mutex::new(watched),
|
watched: Mutex::new(watched),
|
||||||
db_path,
|
db_path,
|
||||||
channels_root,
|
channels_root,
|
||||||
browser,
|
|
||||||
progress_tx: progress_tx.clone(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Background task: poll downloader and broadcast SSE events
|
|
||||||
let poll_state = Arc::clone(&state);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
{
|
|
||||||
let mut dl = poll_state.downloader.lock().unwrap();
|
|
||||||
dl.poll();
|
|
||||||
let snap = WebState::job_snapshots(&dl);
|
|
||||||
drop(dl);
|
|
||||||
if let Ok(json) = serde_json::to_string(&snap) {
|
|
||||||
let _ = poll_state.progress_tx.send(json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(get_index))
|
.route("/", get(get_index))
|
||||||
.route("/api/library", get(get_library))
|
.route("/api/library", get(get_library))
|
||||||
.route("/api/download", post(post_download))
|
|
||||||
.route("/api/progress", get(get_progress))
|
.route("/api/progress", get(get_progress))
|
||||||
.route("/api/events", get(get_events))
|
.route("/api/download", post(post_download))
|
||||||
.route("/api/watched/:id", post(post_watched))
|
.route("/api/watched/:id", post(post_watched))
|
||||||
.route("/api/rescan", post(post_rescan))
|
.route("/api/rescan", post(post_rescan))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let port = config.web.port;
|
let port = config.web.port;
|
||||||
let addr = format!("0.0.0.0:{port}");
|
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}"))
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await.expect("bind");
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("Cannot bind to port {port}: {e}"));
|
||||||
println!("yt-offline web UI: http://localhost:{port}");
|
println!("yt-offline web UI: http://localhost:{port}");
|
||||||
axum::serve(listener, app).await.expect("serve");
|
axum::serve(listener, app).await.expect("server error");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Embedded HTML/JS UI ───────────────────────────────────────────────────────
|
// ── Embedded UI ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const HTML_UI: &str = r#"<!DOCTYPE html>
|
const HTML_UI: &str = r#"<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
@ -304,198 +251,276 @@ const HTML_UI: &str = r#"<!DOCTYPE html>
|
||||||
<title>yt-offline</title>
|
<title>yt-offline</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #1a1a2e; --panel: #16213e; --card: #0f3460;
|
--bg:#1a1a2e; --panel:#16213e; --card:#0f3460;
|
||||||
--accent: #e94560; --text: #eee; --muted: #aaa; --border: #334;
|
--accent:#e94560; --text:#eee; --muted:#999; --border:#334;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
body { background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, sans-serif; display: flex; flex-direction: column; height: 100vh; }
|
body{background:var(--bg);color:var(--text);font:14px/1.5 system-ui,sans-serif;display:flex;flex-direction:column;height:100vh;overflow:hidden}
|
||||||
header { background: var(--panel); padding: 10px 16px; display: flex; gap: 12px; align-items: center; border-bottom: 1px solid var(--border); }
|
header{background:var(--panel);padding:8px 14px;display:flex;gap:10px;align-items:center;border-bottom:1px solid var(--border);flex-shrink:0}
|
||||||
header h1 { font-size: 1.1em; }
|
header h1{font-size:1em;font-weight:700;white-space:nowrap}
|
||||||
header input { flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 6px 10px; border-radius: 4px; }
|
header input{flex:1;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:5px 9px;border-radius:4px;font-size:13px}
|
||||||
button { background: var(--accent); color: #fff; border: none; padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 13px; }
|
button{background:var(--card);color:var(--text);border:1px solid var(--border);padding:5px 12px;border-radius:4px;cursor:pointer;font-size:12px}
|
||||||
button:hover { opacity: 0.85; }
|
button:hover{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||||
button.muted { background: var(--card); }
|
button.primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||||
main { display: flex; flex: 1; overflow: hidden; }
|
main{display:flex;flex:1;overflow:hidden}
|
||||||
aside { width: 220px; background: var(--panel); border-right: 1px solid var(--border); overflow-y: auto; padding: 8px 0; flex-shrink: 0; }
|
aside{width:210px;background:var(--panel);border-right:1px solid var(--border);overflow-y:auto;flex-shrink:0;padding:6px 0}
|
||||||
aside h3 { padding: 6px 12px; font-size: 0.75em; text-transform: uppercase; color: var(--muted); letter-spacing: 0.08em; }
|
.sidebar-label{padding:2px 10px;font-size:10px;text-transform:uppercase;color:var(--muted);letter-spacing:.07em;margin-top:6px}
|
||||||
.ch-item { padding: 6px 12px; cursor: pointer; font-size: 13px; border-left: 3px solid transparent; }
|
.ch-item{padding:5px 10px;cursor:pointer;font-size:13px;border-left:3px solid transparent;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
.ch-item:hover { background: var(--card); }
|
.ch-item:hover{background:var(--card)}
|
||||||
.ch-item.active { border-left-color: var(--accent); background: var(--card); }
|
.ch-item.active{border-left-color:var(--accent);background:rgba(255,255,255,.04)}
|
||||||
.ch-sub { padding: 4px 12px 4px 24px; font-size: 12px; color: var(--muted); cursor: pointer; }
|
.ch-sub{padding:3px 10px 3px 22px;font-size:12px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
.ch-sub:hover { color: var(--text); }
|
.ch-sub:hover{color:var(--text);background:rgba(255,255,255,.03)}
|
||||||
section#content { flex: 1; overflow-y: auto; padding: 12px; }
|
.ch-sub.active{color:var(--text)}
|
||||||
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
section#content{flex:1;overflow-y:auto;padding:10px}
|
||||||
.video-card { background: var(--card); border-radius: 6px; overflow: hidden; cursor: pointer; border: 2px solid transparent; transition: border-color 0.15s; }
|
.toolbar{display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap}
|
||||||
.video-card:hover { border-color: var(--accent); }
|
.toolbar select{background:var(--card);color:var(--text);border:1px solid var(--border);padding:4px 8px;border-radius:4px;font-size:12px}
|
||||||
.video-card.watched { opacity: 0.55; }
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px}
|
||||||
.thumb { width: 100%; aspect-ratio: 16/9; background: #222; display: flex; align-items: center; justify-content: center; font-size: 2em; color: #555; }
|
.card{background:var(--card);border-radius:6px;overflow:hidden;border:2px solid transparent;transition:border-color .15s}
|
||||||
.card-body { padding: 8px; }
|
.card:hover{border-color:var(--accent)}
|
||||||
.card-title { font-size: 13px; font-weight: 600; margin-bottom: 4px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
.card.watched{opacity:.5}
|
||||||
.card-meta { font-size: 11px; color: var(--muted); display: flex; gap: 6px; flex-wrap: wrap; }
|
.card-title{font-size:13px;font-weight:600;padding:8px 8px 2px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:36px}
|
||||||
.card-actions { display: flex; gap: 6px; margin-top: 6px; }
|
.card-meta{font-size:11px;color:var(--muted);padding:0 8px 4px;display:flex;gap:5px;flex-wrap:wrap}
|
||||||
.card-actions button { font-size: 11px; padding: 3px 8px; }
|
.card-foot{padding:4px 8px 8px;display:flex;gap:6px}
|
||||||
#download-bar { background: var(--panel); border-top: 1px solid var(--border); padding: 10px 16px; display: flex; gap: 8px; align-items: center; }
|
.card-foot button{font-size:11px;padding:3px 8px}
|
||||||
#download-bar input { flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 6px 10px; border-radius: 4px; }
|
footer{background:var(--panel);border-top:1px solid var(--border);padding:8px 14px;display:flex;gap:8px;align-items:center;flex-shrink:0}
|
||||||
#jobs { background: var(--panel); border-top: 1px solid var(--border); }
|
footer input{flex:1;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:5px 9px;border-radius:4px;font-size:13px}
|
||||||
.job { padding: 6px 16px; display: flex; align-items: center; gap: 10px; font-size: 12px; border-bottom: 1px solid var(--border); }
|
#jobs{background:var(--panel);border-top:1px solid var(--border);flex-shrink:0}
|
||||||
.job-state { font-weight: 700; min-width: 50px; }
|
.job{display:flex;align-items:center;gap:8px;padding:5px 14px;font-size:12px;border-bottom:1px solid var(--border)}
|
||||||
.job-state.running { color: #facc15; }
|
.badge{font-weight:700;min-width:48px}
|
||||||
.job-state.done { color: #4ade80; }
|
.badge.running{color:#facc15}
|
||||||
.job-state.failed { color: #f87171; }
|
.badge.done{color:#4ade80}
|
||||||
progress { flex: 1; height: 6px; accent-color: var(--accent); }
|
.badge.failed{color:#f87171}
|
||||||
#status { font-size: 12px; color: var(--muted); padding: 0 16px; }
|
progress{flex:1;height:5px;accent-color:var(--accent)}
|
||||||
.badge { background: var(--accent); color: #fff; border-radius: 10px; padding: 1px 7px; font-size: 10px; }
|
.empty{text-align:center;color:var(--muted);padding:40px;font-size:13px}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>yt-offline</h1>
|
<h1>yt-offline</h1>
|
||||||
<input type="search" id="search" placeholder="Filter by title or ID…" oninput="filterCards()">
|
<input type="search" id="search" placeholder="Filter…" oninput="renderGrid()">
|
||||||
<button class="muted" onclick="rescan()">⟳ Rescan</button>
|
<select id="sort" onchange="renderGrid()">
|
||||||
<span id="status"></span>
|
<option value="title">Title</option>
|
||||||
|
<option value="dur-asc">Shortest</option>
|
||||||
|
<option value="dur-desc">Longest</option>
|
||||||
|
<option value="size-asc">Smallest</option>
|
||||||
|
<option value="size-desc">Largest</option>
|
||||||
|
</select>
|
||||||
|
<button onclick="rescan()">⟳ Rescan</button>
|
||||||
|
<span id="status" style="font-size:12px;color:var(--muted)"></span>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<aside id="sidebar"></aside>
|
<aside id="sidebar"></aside>
|
||||||
<section id="content"><div class="video-grid" id="grid"></div></section>
|
<section id="content">
|
||||||
|
<div class="toolbar">
|
||||||
|
<label style="font-size:12px;color:var(--muted)">
|
||||||
|
<input type="checkbox" id="bulk" onchange="toggleBulk()"> Select
|
||||||
|
</label>
|
||||||
|
<span id="bulk-actions" style="display:none;gap:6px;display:none">
|
||||||
|
<button onclick="bulkWatched(true)">✓ Mark watched</button>
|
||||||
|
<button onclick="bulkWatched(false)">○ Unwatch</button>
|
||||||
|
<span id="sel-count" style="font-size:12px;color:var(--muted)"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid" id="grid"></div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<div id="jobs"></div>
|
<div id="jobs"></div>
|
||||||
<div id="download-bar">
|
<footer>
|
||||||
<input type="url" id="dl-url" placeholder="YouTube URL to download…" onkeydown="if(event.key==='Enter')startDownload()">
|
<input type="url" id="dl-url" placeholder="YouTube URL to download…" onkeydown="if(event.key==='Enter')startDownload()">
|
||||||
<button onclick="startDownload()">⬇ Download</button>
|
<button class="primary" onclick="startDownload()">⬇ Download</button>
|
||||||
</div>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let library = [];
|
'use strict';
|
||||||
let activeChannel = null;
|
let library = [], activeChannel = null, activePlaylist = null;
|
||||||
let activePlaylist = null;
|
let bulkMode = false, selected = new Set();
|
||||||
|
|
||||||
|
async function api(path, opts) {
|
||||||
|
const r = await fetch(path, opts);
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadLibrary() {
|
async function loadLibrary() {
|
||||||
const r = await fetch('/api/library');
|
try {
|
||||||
|
const r = await api('/api/library');
|
||||||
library = (await r.json()).channels;
|
library = (await r.json()).channels;
|
||||||
renderSidebar();
|
renderSidebar();
|
||||||
renderGrid();
|
renderGrid();
|
||||||
|
} catch(e) { setStatus('Error: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setStatus(s) { document.getElementById('status').textContent = s; }
|
||||||
|
|
||||||
function renderSidebar() {
|
function renderSidebar() {
|
||||||
const el = document.getElementById('sidebar');
|
const el = document.getElementById('sidebar');
|
||||||
let total = library.reduce((s, c) => s + c.total_videos, 0);
|
const total = library.reduce((s,c)=>s+c.total_videos,0);
|
||||||
let html = `<h3>Channels</h3>
|
let h = `<div class="sidebar-label">Library</div>`;
|
||||||
<div class="ch-item ${!activeChannel ? 'active' : ''}" onclick="selectChannel(null)">⊞ All (${total})</div>`;
|
h += sidebar_item(null, null, `⊞ All (${total})`, activeChannel===null);
|
||||||
for (const ch of library) {
|
for (const ch of library) {
|
||||||
const active = activeChannel === ch.name && !activePlaylist;
|
const active = activeChannel===ch.name && activePlaylist===null;
|
||||||
html += `<div class="ch-item ${active ? 'active' : ''}" onclick="selectChannel('${esc(ch.name)}')">
|
const size = ch.size_bytes > 0 ? ' · '+fmtSize(ch.size_bytes) : '';
|
||||||
${esc(ch.name)} <span style="color:var(--muted);font-size:11px">(${ch.total_videos})</span>
|
h += sidebar_item(ch.name, null, `${esc(ch.name)} (${ch.total_videos}${size})`, active);
|
||||||
</div>`;
|
if (activeChannel === ch.name) {
|
||||||
if (activeChannel === ch.name && ch.playlists.length) {
|
|
||||||
for (const pl of ch.playlists) {
|
for (const pl of ch.playlists) {
|
||||||
const ap = activePlaylist === pl.name;
|
h += `<div class="ch-sub${activePlaylist===pl.name?' active':''}"
|
||||||
html += `<div class="ch-sub ${ap ? 'active' : ''}" onclick="selectPlaylist('${esc(ch.name)}','${esc(pl.name)}')">└ ${esc(pl.name)} (${pl.videos.length})</div>`;
|
onclick="setView(${JSON.stringify(ch.name)},${JSON.stringify(pl.name)})"
|
||||||
|
>└ ${esc(pl.name)} (${pl.videos.length})</div>`;
|
||||||
|
}
|
||||||
|
if (ch.channel_url) {
|
||||||
|
h += `<div class="ch-sub" style="color:var(--accent)"
|
||||||
|
onclick="downloadChannel(${JSON.stringify(ch.channel_url)})">⬇ Check for new videos</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
el.innerHTML = html;
|
el.innerHTML = h;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectChannel(name) {
|
function sidebar_item(ch, pl, label, active) {
|
||||||
activeChannel = name; activePlaylist = null;
|
return `<div class="ch-item${active?' active':''}"
|
||||||
renderSidebar(); renderGrid();
|
onclick="setView(${JSON.stringify(ch)},${JSON.stringify(pl)})">${label}</div>`;
|
||||||
}
|
}
|
||||||
function selectPlaylist(ch, pl) {
|
|
||||||
|
function setView(ch, pl) {
|
||||||
activeChannel = ch; activePlaylist = pl;
|
activeChannel = ch; activePlaylist = pl;
|
||||||
|
selected.clear();
|
||||||
renderSidebar(); renderGrid();
|
renderSidebar(); renderGrid();
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentVideos() {
|
function currentVideos() {
|
||||||
const q = document.getElementById('search').value.toLowerCase();
|
const q = document.getElementById('search').value.toLowerCase();
|
||||||
let videos = [];
|
const sort = document.getElementById('sort').value;
|
||||||
|
let vids = [];
|
||||||
for (const ch of library) {
|
for (const ch of library) {
|
||||||
if (activeChannel && ch.name !== activeChannel) continue;
|
if (activeChannel && ch.name !== activeChannel) continue;
|
||||||
const pool = activePlaylist
|
const pool = activePlaylist
|
||||||
? (ch.playlists.find(p => p.name === activePlaylist)?.videos || [])
|
? (ch.playlists.find(p=>p.name===activePlaylist)?.videos || [])
|
||||||
: [...ch.videos, ...ch.playlists.flatMap(p => p.videos)];
|
: [...ch.videos, ...ch.playlists.flatMap(p=>p.videos)];
|
||||||
for (const v of pool) {
|
for (const v of pool) {
|
||||||
if (!q || v.title.toLowerCase().includes(q) || v.id.includes(q))
|
if (!q || v.title.toLowerCase().includes(q) || v.id.includes(q))
|
||||||
videos.push({...v, channel: ch.name});
|
vids.push({...v, channel: ch.name});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return videos;
|
if (sort==='dur-asc') vids.sort((a,b)=>(a.duration_secs??0)-(b.duration_secs??0));
|
||||||
|
if (sort==='dur-desc') vids.sort((a,b)=>(b.duration_secs??0)-(a.duration_secs??0));
|
||||||
|
if (sort==='size-asc') vids.sort((a,b)=>(a.file_size??0)-(b.file_size??0));
|
||||||
|
if (sort==='size-desc')vids.sort((a,b)=>(b.file_size??0)-(a.file_size??0));
|
||||||
|
if (sort==='title') vids.sort((a,b)=>a.title.localeCompare(b.title));
|
||||||
|
return vids;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterCards() { renderGrid(); }
|
|
||||||
|
|
||||||
function renderGrid() {
|
function renderGrid() {
|
||||||
const videos = currentVideos();
|
const vids = currentVideos();
|
||||||
document.getElementById('status').textContent = `${videos.length} videos`;
|
setStatus(`${vids.length} videos`);
|
||||||
const grid = document.getElementById('grid');
|
const grid = document.getElementById('grid');
|
||||||
grid.innerHTML = videos.map(v => `
|
if (!vids.length) { grid.innerHTML = '<div class="empty">Nothing here.</div>'; return; }
|
||||||
<div class="video-card ${v.watched ? 'watched' : ''}" id="card-${v.id}">
|
grid.innerHTML = vids.map(v => {
|
||||||
<div class="thumb">▶</div>
|
const chk = bulkMode ? `<input type="checkbox" ${selected.has(v.id)?'checked':''} onchange="toggleSel('${v.id}',this.checked)">` : '';
|
||||||
<div class="card-body">
|
const meta = [
|
||||||
<div class="card-title">${esc(v.title)}</div>
|
v.channel !== activeChannel ? esc(v.channel) : null,
|
||||||
<div class="card-meta">
|
v.duration_secs != null ? fmtDur(v.duration_secs) : null,
|
||||||
<span>${esc(v.channel)}</span>
|
v.file_size != null ? fmtSize(v.file_size) : null,
|
||||||
${v.duration_secs ? `<span>${fmtDur(v.duration_secs)}</span>` : ''}
|
v.has_live_chat ? '💬' : null,
|
||||||
${v.file_size ? `<span>${fmtSize(v.file_size)}</span>` : ''}
|
!v.has_video ? '<span style="color:#f87171">no file</span>' : null,
|
||||||
${v.has_live_chat ? '<span>💬</span>' : ''}
|
].filter(Boolean).join(' · ');
|
||||||
${!v.has_video ? '<span style="color:#f87171">no file</span>' : ''}
|
return `<div class="card${v.watched?' watched':''}">
|
||||||
|
<div class="card-title">${chk} ${esc(v.title)}</div>
|
||||||
|
<div class="card-meta">${meta||' '}</div>
|
||||||
|
<div class="card-foot">
|
||||||
|
<button onclick="toggleWatched('${v.id}')">${v.watched?'✓ Watched':'○ Watch'}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
</div>`;
|
||||||
<button onclick="toggleWatched('${v.id}')">${v.watched ? '✓ Watched' : '○ Watch'}</button>
|
}).join('');
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleWatched(id) {
|
async function toggleWatched(id) {
|
||||||
await fetch(`/api/watched/${id}`, {method:'POST'});
|
try { await api(`/api/watched/${id}`, {method:'POST'}); await loadLibrary(); }
|
||||||
|
catch(e) { setStatus('Error: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBulk() {
|
||||||
|
bulkMode = document.getElementById('bulk').checked;
|
||||||
|
selected.clear();
|
||||||
|
document.getElementById('bulk-actions').style.display = bulkMode ? 'flex' : 'none';
|
||||||
|
renderGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSel(id, on) {
|
||||||
|
if (on) selected.add(id); else selected.delete(id);
|
||||||
|
document.getElementById('sel-count').textContent = selected.size + ' selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkWatched(on) {
|
||||||
|
for (const id of selected) {
|
||||||
|
const db = await api('/api/library');
|
||||||
|
const lib = (await db.json()).channels;
|
||||||
|
// find current state
|
||||||
|
}
|
||||||
|
// simpler: just toggle each
|
||||||
|
const ids = [...selected];
|
||||||
|
await Promise.all(ids.map(id => api(`/api/watched/${id}`, {method:'POST'})));
|
||||||
|
selected.clear();
|
||||||
await loadLibrary();
|
await loadLibrary();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startDownload() {
|
async function startDownload() {
|
||||||
const url = document.getElementById('dl-url').value.trim();
|
const url = document.getElementById('dl-url').value.trim();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
await fetch('/api/download', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({url})});
|
try {
|
||||||
|
await api('/api/download', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({url})});
|
||||||
document.getElementById('dl-url').value = '';
|
document.getElementById('dl-url').value = '';
|
||||||
|
setStatus('Download started…');
|
||||||
|
} catch(e) { setStatus('Error: '+e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadChannel(url) {
|
||||||
|
try {
|
||||||
|
await api('/api/download', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({url})});
|
||||||
|
setStatus('Checking for new videos…');
|
||||||
|
} catch(e) { setStatus('Error: '+e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rescan() {
|
async function rescan() {
|
||||||
await fetch('/api/rescan', {method:'POST'});
|
try { await api('/api/rescan', {method:'POST'}); await loadLibrary(); }
|
||||||
await loadLibrary();
|
catch(e) { setStatus('Error: '+e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSE progress
|
|
||||||
const es = new EventSource('/api/events');
|
|
||||||
es.onmessage = e => {
|
|
||||||
try {
|
|
||||||
const jobs = JSON.parse(e.data);
|
|
||||||
renderJobs(jobs);
|
|
||||||
} catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
function renderJobs(jobs) {
|
function renderJobs(jobs) {
|
||||||
if (!jobs.length) { document.getElementById('jobs').innerHTML = ''; return; }
|
const el = document.getElementById('jobs');
|
||||||
document.getElementById('jobs').innerHTML = jobs.map(j => `
|
if (!jobs.length) { el.innerHTML=''; return; }
|
||||||
<div class="job">
|
el.innerHTML = jobs.map(j=>`<div class="job">
|
||||||
<span class="job-state ${j.state}">${j.state}</span>
|
<span class="badge ${j.state}">${j.state}</span>
|
||||||
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.label)} — ${esc(j.url)}</span>
|
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.label)} — ${esc(j.url)}</span>
|
||||||
${j.state==='running' ? `<progress value="${j.progress}" max="1"></progress>` : ''}
|
${j.state==='running'?`<progress value="${j.progress}" max="1"></progress>`:''}
|
||||||
|
<span style="font-size:11px;color:var(--muted);max-width:200px;overflow:hidden;text-overflow:ellipsis">${esc(j.last_line)}</span>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
// Rescan library when all done
|
|
||||||
if (jobs.length && jobs.every(j => j.state !== 'running')) loadLibrary();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Poll progress every 600ms
|
||||||
|
let wasRunning = false;
|
||||||
|
async function pollProgress() {
|
||||||
|
try {
|
||||||
|
const r = await api('/api/progress');
|
||||||
|
const {jobs} = await r.json();
|
||||||
|
renderJobs(jobs);
|
||||||
|
const running = jobs.some(j=>j.state==='running');
|
||||||
|
if (wasRunning && !running) await loadLibrary();
|
||||||
|
wasRunning = running;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
setInterval(pollProgress, 600);
|
||||||
|
|
||||||
function fmtDur(s) {
|
function fmtDur(s) {
|
||||||
s = Math.floor(s);
|
s=Math.floor(s); const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=s%60;
|
||||||
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = s%60;
|
return h?`${h}:${p(m)}:${p(sec)}`:`${m}:${p(sec)}`;
|
||||||
return h ? `${h}:${p(m)}:${p(sec)}` : `${m}:${p(sec)}`;
|
|
||||||
}
|
}
|
||||||
function fmtSize(b) {
|
function fmtSize(b) {
|
||||||
if (b>=1073741824) return (b/1073741824).toFixed(1)+' GB';
|
if(b>=1073741824)return(b/1073741824).toFixed(1)+' GB';
|
||||||
if (b>=1048576) return Math.round(b/1048576)+' MB';
|
if(b>=1048576)return Math.round(b/1048576)+' MB';
|
||||||
return Math.round(b/1024)+' KB';
|
return Math.round(b/1024)+' KB';
|
||||||
}
|
}
|
||||||
function p(n) { return String(n).padStart(2,'0'); }
|
function p(n){return String(n).padStart(2,'0')}
|
||||||
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
function esc(s){return String(s??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||||
|
|
||||||
loadLibrary();
|
loadLibrary();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue