Add web UI, AGPL3, notifications, continue watching, bulk watched, storage stats, scheduler
License: - GPL3 → AGPL3 New features: - Web interface (--web [port]): axum/tokio server with SSE progress, library browser, download trigger, watched toggle; embedded single-page HTML/JS UI - Desktop notifications via notify-rust when downloads complete or fail - Continue Watching sidebar entry showing videos with saved resume positions - Bulk select mode: select multiple videos, mark all watched/unwatched at once - Storage stats: channel disk usage shown in sidebar next to video count - Scheduled channel checks: configurable interval (hours), auto re-downloads all tracked channels using channel_url from info.json; enabled in Settings Theme fixes: - Scene Queen: remove override_text_color so active button text uses fg_stroke (dark navy on neon green) instead of near-white — fixes unreadable contrast - Trans: override_text_color → hot pink #cc0066 for pink-tinted text throughout Settings additions: - Auto-check channels toggle + interval_hours DragValue - Web UI port setting Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5b3b8fc901
commit
80cce00b99
8 changed files with 1526 additions and 211 deletions
427
src/app.rs
427
src/app.rs
|
|
@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet};
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::time::Instant;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
|
|
@ -30,6 +31,14 @@ enum SortMode {
|
|||
SizeDesc,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum SidebarView {
|
||||
All,
|
||||
Channel(usize),
|
||||
Playlist(usize, usize),
|
||||
ContinueWatching,
|
||||
}
|
||||
|
||||
struct Card {
|
||||
channel_name: String,
|
||||
title: String,
|
||||
|
|
@ -48,8 +57,7 @@ pub struct App {
|
|||
config_path: PathBuf,
|
||||
channels_root: PathBuf,
|
||||
library: Vec<library::Channel>,
|
||||
selected_channel: Option<usize>,
|
||||
selected_playlist: Option<(usize, usize)>,
|
||||
sidebar_view: SidebarView,
|
||||
selected_video: Option<String>,
|
||||
search: String,
|
||||
downloader: Downloader,
|
||||
|
|
@ -66,9 +74,14 @@ pub struct App {
|
|||
sort_mode: SortMode,
|
||||
watched: HashSet<String>,
|
||||
resume_positions: HashMap<String, f64>,
|
||||
prev_any_running: bool,
|
||||
prev_job_states: HashMap<usize, JobState>,
|
||||
currently_playing: Option<String>,
|
||||
mpv_rx: Option<Receiver<(String, f64)>>,
|
||||
// Bulk selection
|
||||
bulk_mode: bool,
|
||||
bulk_selected: HashSet<String>,
|
||||
// Scheduler
|
||||
last_scheduled_check: Option<Instant>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
|
|
@ -109,8 +122,7 @@ impl App {
|
|||
config_path,
|
||||
channels_root: channels_root.clone(),
|
||||
library,
|
||||
selected_channel: None,
|
||||
selected_playlist: None,
|
||||
sidebar_view: SidebarView::All,
|
||||
selected_video: None,
|
||||
search: String::new(),
|
||||
downloader: Downloader::new(channels_root, browser),
|
||||
|
|
@ -127,16 +139,18 @@ impl App {
|
|||
sort_mode: SortMode::Title,
|
||||
watched,
|
||||
resume_positions,
|
||||
prev_any_running: false,
|
||||
prev_job_states: HashMap::new(),
|
||||
currently_playing: None,
|
||||
mpv_rx: None,
|
||||
bulk_mode: false,
|
||||
bulk_selected: HashSet::new(),
|
||||
last_scheduled_check: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rescan(&mut self) {
|
||||
self.library = library::scan_channels(&self.channels_root);
|
||||
self.selected_channel = None;
|
||||
self.selected_playlist = None;
|
||||
self.sidebar_view = SidebarView::All;
|
||||
self.selected_video = None;
|
||||
self.desc_cache.clear();
|
||||
self.textures.clear();
|
||||
|
|
@ -150,51 +164,68 @@ impl App {
|
|||
fn cards(&self) -> Vec<Card> {
|
||||
let query = self.search.trim().to_lowercase();
|
||||
|
||||
let channel_indices: Vec<usize> = if let Some(ci) = self.selected_channel {
|
||||
vec![ci]
|
||||
} else {
|
||||
(0..self.library.len()).collect()
|
||||
let mut cards = Vec::new();
|
||||
|
||||
let add_video = |cards: &mut Vec<Card>, ch_name: &str, v: &library::Video| {
|
||||
if !query.is_empty()
|
||||
&& !v.title.to_lowercase().contains(&query)
|
||||
&& !v.id.to_lowercase().contains(&query)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let resume_pos = self.resume_positions.get(&v.id).copied();
|
||||
cards.push(Card {
|
||||
channel_name: ch_name.to_string(),
|
||||
title: v.title.clone(),
|
||||
id: v.id.clone(),
|
||||
video_path: v.video_path.clone(),
|
||||
thumb_path: v.thumb_path.clone(),
|
||||
has_live_chat: v.has_live_chat,
|
||||
duration_secs: v.duration_secs,
|
||||
file_size: v.file_size,
|
||||
watched: self.watched.contains(&v.id),
|
||||
resume_pos,
|
||||
});
|
||||
};
|
||||
|
||||
let mut cards = Vec::new();
|
||||
for ci in channel_indices {
|
||||
let Some(channel) = self.library.get(ci) else { continue };
|
||||
|
||||
let playlist_filter = match self.selected_playlist {
|
||||
Some((pci, pi)) if pci == ci => Some(pi),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let videos_iter: Box<dyn Iterator<Item = &library::Video>> = if let Some(pi) = playlist_filter {
|
||||
let Some(playlist) = channel.playlists.get(pi) else { continue };
|
||||
Box::new(playlist.videos.iter())
|
||||
} else {
|
||||
Box::new(
|
||||
channel.videos.iter()
|
||||
.chain(channel.playlists.iter().flat_map(|p| p.videos.iter()))
|
||||
)
|
||||
};
|
||||
|
||||
for v in videos_iter {
|
||||
if !query.is_empty()
|
||||
&& !v.title.to_lowercase().contains(&query)
|
||||
&& !v.id.to_lowercase().contains(&query)
|
||||
{
|
||||
continue;
|
||||
match &self.sidebar_view {
|
||||
SidebarView::ContinueWatching => {
|
||||
for ch in &self.library {
|
||||
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
|
||||
if self.resume_positions.contains_key(&v.id) {
|
||||
add_video(&mut cards, &ch.name, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
let resume_pos = self.resume_positions.get(&v.id).copied();
|
||||
cards.push(Card {
|
||||
channel_name: channel.name.clone(),
|
||||
title: v.title.clone(),
|
||||
id: v.id.clone(),
|
||||
video_path: v.video_path.clone(),
|
||||
thumb_path: v.thumb_path.clone(),
|
||||
has_live_chat: v.has_live_chat,
|
||||
duration_secs: v.duration_secs,
|
||||
file_size: v.file_size,
|
||||
watched: self.watched.contains(&v.id),
|
||||
resume_pos,
|
||||
cards.sort_by(|a, b| {
|
||||
b.resume_pos.unwrap_or(0.0)
|
||||
.partial_cmp(&a.resume_pos.unwrap_or(0.0))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
return cards;
|
||||
}
|
||||
SidebarView::All => {
|
||||
for ch in &self.library {
|
||||
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
|
||||
add_video(&mut cards, &ch.name, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
SidebarView::Channel(ci) => {
|
||||
if let Some(ch) = self.library.get(*ci) {
|
||||
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
|
||||
add_video(&mut cards, &ch.name, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
SidebarView::Playlist(ci, pi) => {
|
||||
if let Some(ch) = self.library.get(*ci) {
|
||||
if let Some(pl) = ch.playlists.get(*pi) {
|
||||
for v in &pl.videos {
|
||||
add_video(&mut cards, &ch.name, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -334,6 +365,76 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
fn bulk_mark_watched(&mut self, watched: bool) {
|
||||
let ids: Vec<String> = self.bulk_selected.iter().cloned().collect();
|
||||
for id in &ids {
|
||||
if let Ok(()) = self.db.set_watched(id, watched) {
|
||||
if watched {
|
||||
self.watched.insert(id.clone());
|
||||
} else {
|
||||
self.watched.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.bulk_selected.clear();
|
||||
self.status = format!(
|
||||
"{} {} as {}watched",
|
||||
ids.len(),
|
||||
if ids.len() == 1 { "video" } else { "videos" },
|
||||
if watched { "" } else { "un" }
|
||||
);
|
||||
}
|
||||
|
||||
fn run_scheduled_check(&mut self) {
|
||||
let mut count = 0;
|
||||
let urls: Vec<String> = self.library.iter()
|
||||
.filter_map(|ch| ch.meta.as_ref()?.channel_url.clone())
|
||||
.collect();
|
||||
for url in urls {
|
||||
let kind = detect_url_kind(&url);
|
||||
self.downloader.start(url, &kind);
|
||||
count += 1;
|
||||
}
|
||||
self.status = format!("Scheduled check: started {} channel downloads", count);
|
||||
}
|
||||
|
||||
fn check_notifications(&mut self) {
|
||||
let jobs = &self.downloader.jobs;
|
||||
let mut finished: Vec<(String, bool)> = Vec::new();
|
||||
|
||||
for (i, job) in jobs.iter().enumerate() {
|
||||
let prev = self.prev_job_states.get(&i).copied();
|
||||
if prev == Some(JobState::Running) && job.state != JobState::Running {
|
||||
finished.push((job.label.clone(), job.state == JobState::Done));
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild snapshot
|
||||
self.prev_job_states = jobs.iter().enumerate()
|
||||
.map(|(i, j)| (i, j.state))
|
||||
.collect();
|
||||
|
||||
for (label, ok) in finished {
|
||||
let summary = if ok {
|
||||
format!("Download complete: {label}")
|
||||
} else {
|
||||
format!("Download failed: {label}")
|
||||
};
|
||||
let _ = notify_rust::Notification::new()
|
||||
.summary("yt-offline")
|
||||
.body(&summary)
|
||||
.timeout(notify_rust::Timeout::Milliseconds(4000))
|
||||
.show();
|
||||
}
|
||||
}
|
||||
|
||||
fn channel_total_size(ch: &library::Channel) -> u64 {
|
||||
ch.videos.iter()
|
||||
.chain(ch.playlists.iter().flat_map(|p| p.videos.iter()))
|
||||
.filter_map(|v| v.file_size)
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn top_bar(&mut self, ctx: &egui::Context) {
|
||||
egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
|
||||
ui.add_space(2.0);
|
||||
|
|
@ -388,45 +489,76 @@ impl App {
|
|||
ui.separator();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
let total: usize = self.library.iter().map(|c| c.total_videos()).sum();
|
||||
let resume_count = self.resume_positions.len();
|
||||
|
||||
if ui
|
||||
.selectable_label(
|
||||
self.selected_channel.is_none(),
|
||||
self.sidebar_view == SidebarView::All,
|
||||
format!("⊞ All ({total})"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.selected_channel = None;
|
||||
self.selected_playlist = None;
|
||||
self.sidebar_view = SidebarView::All;
|
||||
self.selected_video = None;
|
||||
}
|
||||
ui.separator();
|
||||
for i in 0..self.library.len() {
|
||||
let is_selected = self.selected_channel == Some(i);
|
||||
let (name, total, has_playlists) = {
|
||||
let ch = &self.library[i];
|
||||
(ch.name.clone(), ch.total_videos(), !ch.playlists.is_empty())
|
||||
};
|
||||
let label = format!("{} ({})", name, total);
|
||||
|
||||
if resume_count > 0 {
|
||||
if ui
|
||||
.selectable_label(is_selected && self.selected_playlist.is_none(), label)
|
||||
.selectable_label(
|
||||
self.sidebar_view == SidebarView::ContinueWatching,
|
||||
format!("▶ Continue Watching ({resume_count})"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.sidebar_view = SidebarView::ContinueWatching;
|
||||
self.selected_video = None;
|
||||
}
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
for i in 0..self.library.len() {
|
||||
let (name, total, has_playlists, size_bytes) = {
|
||||
let ch = &self.library[i];
|
||||
(
|
||||
ch.name.clone(),
|
||||
ch.total_videos(),
|
||||
!ch.playlists.is_empty(),
|
||||
Self::channel_total_size(ch),
|
||||
)
|
||||
};
|
||||
|
||||
let is_ch_selected = matches!(self.sidebar_view, SidebarView::Channel(ci) if ci == i)
|
||||
|| matches!(self.sidebar_view, SidebarView::Playlist(ci, _) if ci == i);
|
||||
|
||||
let size_str = if size_bytes > 0 {
|
||||
format!(" · {}", format_size(size_bytes))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let label = format!("{} ({}{})", name, total, size_str);
|
||||
|
||||
let ch_selected_no_pl = matches!(self.sidebar_view, SidebarView::Channel(ci) if ci == i);
|
||||
if ui
|
||||
.selectable_label(ch_selected_no_pl, label)
|
||||
.on_hover_text(self.library[i].path.display().to_string())
|
||||
.clicked()
|
||||
{
|
||||
self.selected_channel = Some(i);
|
||||
self.selected_playlist = None;
|
||||
self.sidebar_view = SidebarView::Channel(i);
|
||||
self.selected_video = None;
|
||||
}
|
||||
if is_selected && has_playlists {
|
||||
|
||||
if is_ch_selected && has_playlists {
|
||||
let playlist_count = self.library[i].playlists.len();
|
||||
for pi in 0..playlist_count {
|
||||
let (pl_name, pl_len) = {
|
||||
let pl = &self.library[i].playlists[pi];
|
||||
(pl.name.clone(), pl.videos.len())
|
||||
};
|
||||
let is_pl = self.selected_playlist == Some((i, pi));
|
||||
let is_pl = matches!(self.sidebar_view, SidebarView::Playlist(ci, pli) if ci == i && pli == pi);
|
||||
let pl_label = format!(" └ {} ({})", pl_name, pl_len);
|
||||
if ui.selectable_label(is_pl, pl_label).clicked() {
|
||||
self.selected_playlist = Some((i, pi));
|
||||
self.sidebar_view = SidebarView::Playlist(i, pi);
|
||||
self.selected_video = None;
|
||||
}
|
||||
}
|
||||
|
|
@ -486,6 +618,7 @@ impl App {
|
|||
&& ui.button("Clear finished").clicked()
|
||||
{
|
||||
self.downloader.jobs.retain(|j| j.state == JobState::Running);
|
||||
self.prev_job_states.clear();
|
||||
}
|
||||
});
|
||||
if self.downloader.jobs.is_empty() {
|
||||
|
|
@ -537,7 +670,7 @@ impl App {
|
|||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(460.0)
|
||||
.default_width(480.0)
|
||||
.show(ctx, |ui| {
|
||||
egui::Grid::new("settings_grid")
|
||||
.num_columns(2)
|
||||
|
|
@ -603,6 +736,25 @@ impl App {
|
|||
}
|
||||
});
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Auto-check channels:");
|
||||
ui.checkbox(&mut self.config.scheduler.enabled, "enabled");
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Check interval (hours):");
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut self.config.scheduler.interval_hours)
|
||||
.range(1..=168)
|
||||
.suffix("h"),
|
||||
);
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Web UI port:");
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut self.config.web.port)
|
||||
.range(1024..=65535),
|
||||
);
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
|
@ -729,10 +881,10 @@ impl App {
|
|||
|
||||
fn video_list(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
|
||||
let cards = self.cards();
|
||||
let show_channel = self.selected_channel.is_none();
|
||||
let show_channel = !matches!(self.sidebar_view, SidebarView::Channel(_) | SidebarView::Playlist(_, _));
|
||||
|
||||
// Channel metadata banner
|
||||
if let Some(ci) = self.selected_channel {
|
||||
if let SidebarView::Channel(ci) = self.sidebar_view {
|
||||
if let Some(ch) = self.library.get(ci) {
|
||||
if let Some(meta) = &ch.meta {
|
||||
ui.group(|ui| {
|
||||
|
|
@ -755,14 +907,44 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
// Sort controls
|
||||
// Bulk mode toolbar
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("{} videos", cards.len()));
|
||||
let label_text = if self.bulk_mode {
|
||||
format!("{} videos", cards.len())
|
||||
} else {
|
||||
format!("{} videos", cards.len())
|
||||
};
|
||||
ui.label(label_text);
|
||||
|
||||
if !self.search.trim().is_empty() {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("(filtered by \"{}\")", self.search.trim())).weak(),
|
||||
);
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui.selectable_label(self.bulk_mode, "☑ Select").clicked() {
|
||||
self.bulk_mode = !self.bulk_mode;
|
||||
if !self.bulk_mode {
|
||||
self.bulk_selected.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if self.bulk_mode && !self.bulk_selected.is_empty() {
|
||||
ui.separator();
|
||||
let n = self.bulk_selected.len();
|
||||
ui.label(format!("{n} selected"));
|
||||
if ui.button("✓ Mark watched").clicked() {
|
||||
self.bulk_mark_watched(true);
|
||||
self.bulk_mode = false;
|
||||
}
|
||||
if ui.button("○ Mark unwatched").clicked() {
|
||||
self.bulk_mark_watched(false);
|
||||
self.bulk_mode = false;
|
||||
}
|
||||
}
|
||||
|
||||
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::SizeAsc, "Size ↑");
|
||||
|
|
@ -779,13 +961,15 @@ impl App {
|
|||
ui.add_space(20.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(egui::RichText::new("Nothing here.").weak());
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Drop a yt-dlp download into channels/<name>/, or use the Downloads panel.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
if !matches!(self.sidebar_view, SidebarView::ContinueWatching) {
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Drop a yt-dlp download into channels/<name>/, or use the Downloads panel.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -799,6 +983,7 @@ impl App {
|
|||
for card in &cards {
|
||||
let selected = self.selected_video.as_deref() == Some(card.id.as_str());
|
||||
let is_playing = self.currently_playing.as_deref() == Some(card.id.as_str());
|
||||
let bulk_checked = self.bulk_selected.contains(&card.id);
|
||||
let mut clicked_card = false;
|
||||
let mut play_card = false;
|
||||
let mut toggle_watched_card = false;
|
||||
|
|
@ -838,7 +1023,13 @@ impl App {
|
|||
egui::Stroke::new(2.0, egui::Color32::from_rgb(110, 200, 110)),
|
||||
);
|
||||
}
|
||||
// Watched overlay
|
||||
if bulk_checked {
|
||||
ui.painter().rect_stroke(
|
||||
rect,
|
||||
4.0,
|
||||
egui::Stroke::new(3.0, egui::Color32::from_rgb(180, 130, 240)),
|
||||
);
|
||||
}
|
||||
if card.watched {
|
||||
ui.painter().rect_filled(
|
||||
egui::Rect::from_min_size(
|
||||
|
|
@ -866,7 +1057,7 @@ impl App {
|
|||
|
||||
ui.vertical(|ui| {
|
||||
let title_color = if card.watched {
|
||||
egui::Color32::from_gray(140)
|
||||
ui.visuals().weak_text_color()
|
||||
} else {
|
||||
ui.visuals().text_color()
|
||||
};
|
||||
|
|
@ -907,26 +1098,42 @@ impl App {
|
|||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if card.video_path.is_some() && ui.small_button("▶ Play").clicked() {
|
||||
play_card = true;
|
||||
}
|
||||
if let Some(pos) = card.resume_pos {
|
||||
if pos > 5.0 && ui.small_button(format!("⏩ {}", format_duration(pos))).clicked() {
|
||||
if self.bulk_mode {
|
||||
let chk_label = if bulk_checked { "☑" } else { "☐" };
|
||||
if ui.small_button(chk_label).clicked() {
|
||||
clicked_card = true; // handled below
|
||||
}
|
||||
} else {
|
||||
if card.video_path.is_some() && ui.small_button("▶ Play").clicked() {
|
||||
play_card = true;
|
||||
}
|
||||
}
|
||||
if ui.small_button("Details").clicked() {
|
||||
clicked_card = true;
|
||||
}
|
||||
let w_label = if card.watched { "✓" } else { "○" };
|
||||
if ui.small_button(w_label).on_hover_text("Toggle watched").clicked() {
|
||||
toggle_watched_card = true;
|
||||
if let Some(pos) = card.resume_pos {
|
||||
if pos > 5.0 && ui.small_button(format!("⏩ {}", format_duration(pos))).clicked() {
|
||||
play_card = true;
|
||||
}
|
||||
}
|
||||
if ui.small_button("Details").clicked() {
|
||||
clicked_card = true;
|
||||
}
|
||||
let w_label = if card.watched { "✓" } else { "○" };
|
||||
if ui.small_button(w_label).on_hover_text("Toggle watched").clicked() {
|
||||
toggle_watched_card = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if play_card {
|
||||
if self.bulk_mode {
|
||||
if clicked_card {
|
||||
let id = card.id.clone();
|
||||
if self.bulk_selected.contains(&id) {
|
||||
self.bulk_selected.remove(&id);
|
||||
} else {
|
||||
self.bulk_selected.insert(id);
|
||||
}
|
||||
}
|
||||
} else if play_card {
|
||||
if let Some(p) = card.video_path.clone() {
|
||||
let id = card.id.clone();
|
||||
self.play_with_tracking(&p, id);
|
||||
|
|
@ -949,18 +1156,33 @@ impl App {
|
|||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.downloader.poll();
|
||||
self.check_notifications();
|
||||
|
||||
let any_running = self.downloader.any_running();
|
||||
if self.prev_any_running && !any_running && !self.downloader.jobs.is_empty() {
|
||||
// Auto-rescan when all downloads finish
|
||||
let was_running = self.prev_job_states.values().any(|&s| s == JobState::Running);
|
||||
if was_running && !any_running {
|
||||
self.rescan();
|
||||
}
|
||||
self.prev_any_running = any_running;
|
||||
|
||||
if any_running {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(250));
|
||||
}
|
||||
|
||||
// Poll mpv position updates from background tracker thread
|
||||
// Scheduled channel checks
|
||||
if self.config.scheduler.enabled && !any_running {
|
||||
let interval = std::time::Duration::from_secs(
|
||||
self.config.scheduler.interval_hours as u64 * 3600,
|
||||
);
|
||||
let due = self.last_scheduled_check
|
||||
.map_or(true, |t| t.elapsed() >= interval);
|
||||
if due {
|
||||
self.last_scheduled_check = Some(Instant::now());
|
||||
self.run_scheduled_check();
|
||||
}
|
||||
}
|
||||
|
||||
// Poll mpv position updates
|
||||
if let Some(rx) = &self.mpv_rx {
|
||||
while let Ok((video_id, pos)) = rx.try_recv() {
|
||||
let _ = self.db.set_position(&video_id, pos);
|
||||
|
|
@ -989,7 +1211,6 @@ fn spawn_mpv_tracker(sock_path: String, video_id: String, tx: std::sync::mpsc::S
|
|||
use std::os::unix::net::UnixStream;
|
||||
use std::time::Duration;
|
||||
|
||||
// Wait for mpv to create the socket (up to 10 seconds)
|
||||
let mut stream = None;
|
||||
for _ in 0..20 {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
|
@ -1002,7 +1223,6 @@ fn spawn_mpv_tracker(sock_path: String, video_id: String, tx: std::sync::mpsc::S
|
|||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
|
||||
stream.set_read_timeout(Some(Duration::from_secs(3))).ok();
|
||||
|
||||
let mut buf = [0u8; 4096];
|
||||
|
|
@ -1029,10 +1249,7 @@ fn spawn_mpv_tracker(sock_path: String, video_id: String, tx: std::sync::mpsc::S
|
|||
}
|
||||
Err(e)
|
||||
if e.kind() == std::io::ErrorKind::WouldBlock
|
||||
|| e.kind() == std::io::ErrorKind::TimedOut =>
|
||||
{
|
||||
// no response yet, mpv may be paused — keep polling
|
||||
}
|
||||
|| e.kind() == std::io::ErrorKind::TimedOut => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
|
@ -1058,11 +1275,7 @@ fn format_duration(secs: f64) -> String {
|
|||
let h = secs / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
let s = secs % 60;
|
||||
if h > 0 {
|
||||
format!("{h}:{m:02}:{s:02}")
|
||||
} else {
|
||||
format!("{m}:{s:02}")
|
||||
}
|
||||
if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") }
|
||||
}
|
||||
|
||||
fn format_size(bytes: u64) -> String {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ pub struct Config {
|
|||
pub player: PlayerSection,
|
||||
#[serde(default)]
|
||||
pub ui: UiSection,
|
||||
#[serde(default)]
|
||||
pub scheduler: SchedulerSection,
|
||||
#[serde(default)]
|
||||
pub web: WebSection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
|
|
@ -41,9 +45,37 @@ impl Default for UiSection {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SchedulerSection {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_interval_hours")]
|
||||
pub interval_hours: u32,
|
||||
}
|
||||
|
||||
impl Default for SchedulerSection {
|
||||
fn default() -> Self {
|
||||
Self { enabled: false, interval_hours: default_interval_hours() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct WebSection {
|
||||
#[serde(default = "default_web_port")]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl Default for WebSection {
|
||||
fn default() -> Self {
|
||||
Self { port: default_web_port() }
|
||||
}
|
||||
}
|
||||
|
||||
fn default_player() -> String { "mpv".to_string() }
|
||||
fn default_browser() -> String { "firefox".to_string() }
|
||||
fn default_theme() -> String { "dark".to_string() }
|
||||
fn default_interval_hours() -> u32 { 24 }
|
||||
fn default_web_port() -> u16 { 8080 }
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
|
|
@ -62,6 +94,8 @@ impl Config {
|
|||
backup: BackupSection { directory: dir },
|
||||
player: PlayerSection::default(),
|
||||
ui: UiSection::default(),
|
||||
scheduler: SchedulerSection::default(),
|
||||
web: WebSection::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
src/main.rs
19
src/main.rs
|
|
@ -7,8 +7,27 @@ mod downloader;
|
|||
mod library;
|
||||
mod theme;
|
||||
mod tray;
|
||||
mod web;
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// --web [port] → run the web interface instead of the GUI
|
||||
if let Some(pos) = args.iter().position(|a| a == "--web") {
|
||||
let mut cfg = {
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
config::Config::load(&cwd.join("config.toml"))
|
||||
.unwrap_or_else(|_| config::Config::default_with_dir(cwd.join("channels")))
|
||||
};
|
||||
// Override port if provided after --web
|
||||
if let Some(port_str) = args.get(pos + 1) {
|
||||
if let Ok(port) = port_str.parse::<u16>() {
|
||||
cfg.web.port = port;
|
||||
}
|
||||
}
|
||||
web::run(cfg);
|
||||
}
|
||||
|
||||
let native_options = eframe::NativeOptions {
|
||||
viewport: eframe::egui::ViewportBuilder::default()
|
||||
.with_inner_size([1280.0, 820.0])
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ fn trans() -> egui::Visuals {
|
|||
v.selection.bg_fill = hex(0x55cdfc);
|
||||
v.selection.stroke = Stroke::new(1.0, hex(0x2288cc));
|
||||
v.hyperlink_color = hex(0x0055aa);
|
||||
v.override_text_color = Some(hex(0x222222));
|
||||
v.override_text_color = Some(hex(0xcc0066)); // hot pink text throughout
|
||||
v.widgets.noninteractive.bg_fill = hex(0xfce8f2);
|
||||
v.widgets.noninteractive.weak_bg_fill = hex(0xfdf4f8);
|
||||
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0x444444));
|
||||
|
|
@ -161,7 +161,7 @@ fn emo_scene_queen() -> egui::Visuals {
|
|||
v.selection.bg_fill = hex(0x39ff14);
|
||||
v.selection.stroke = Stroke::new(1.0, hex(0x66ff44));
|
||||
v.hyperlink_color = hex(0xff00ff);
|
||||
v.override_text_color = Some(hex(0xd0d0ff));
|
||||
v.override_text_color = None; // let widget fg_stroke handle per-state text color
|
||||
v.widgets.noninteractive.bg_fill = hex(0x111128);
|
||||
v.widgets.noninteractive.weak_bg_fill = hex(0x0d0d20);
|
||||
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xc8c8ff));
|
||||
|
|
|
|||
503
src/web.rs
Normal file
503
src/web.rs
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
//! 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::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Sse},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::StreamExt as _;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::database::Database;
|
||||
use crate::downloader::{detect_url_kind, Downloader, JobState};
|
||||
use crate::library;
|
||||
|
||||
// ── Shared state ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct JobSnapshot {
|
||||
pub label: String,
|
||||
pub url: String,
|
||||
pub state: &'static str,
|
||||
pub progress: f32,
|
||||
pub last_line: String,
|
||||
}
|
||||
|
||||
pub struct WebState {
|
||||
pub library: Mutex<Vec<library::Channel>>,
|
||||
pub downloader: Mutex<Downloader>,
|
||||
pub watched: Mutex<HashSet<String>>,
|
||||
pub db_path: PathBuf,
|
||||
pub channels_root: PathBuf,
|
||||
pub browser: String,
|
||||
/// Broadcast channel for SSE progress events
|
||||
pub progress_tx: broadcast::Sender<String>,
|
||||
}
|
||||
|
||||
impl WebState {
|
||||
pub fn job_snapshots(dl: &Downloader) -> Vec<JobSnapshot> {
|
||||
dl.jobs
|
||||
.iter()
|
||||
.map(|j| JobSnapshot {
|
||||
label: j.label.clone(),
|
||||
url: j.url.clone(),
|
||||
state: match j.state {
|
||||
JobState::Running => "running",
|
||||
JobState::Done => "done",
|
||||
JobState::Failed => "failed",
|
||||
},
|
||||
progress: j.progress,
|
||||
last_line: j.log.last().cloned().unwrap_or_default(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── API types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LibraryResponse {
|
||||
channels: Vec<ChannelInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ChannelInfo {
|
||||
name: String,
|
||||
total_videos: usize,
|
||||
size_bytes: u64,
|
||||
subscriber_count: Option<u64>,
|
||||
uploader: Option<String>,
|
||||
channel_url: Option<String>,
|
||||
playlists: Vec<PlaylistInfo>,
|
||||
videos: Vec<VideoInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PlaylistInfo {
|
||||
name: String,
|
||||
videos: Vec<VideoInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct VideoInfo {
|
||||
id: String,
|
||||
title: String,
|
||||
duration_secs: Option<f64>,
|
||||
file_size: Option<u64>,
|
||||
has_video: bool,
|
||||
has_live_chat: bool,
|
||||
watched: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StartDownloadRequest {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProgressResponse {
|
||||
jobs: Vec<JobSnapshot>,
|
||||
}
|
||||
|
||||
// ── Route handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
async fn get_index() -> impl IntoResponse {
|
||||
(
|
||||
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||
HTML_UI,
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_library(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||
let lib = state.library.lock().unwrap();
|
||||
let watched = state.watched.lock().unwrap();
|
||||
|
||||
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();
|
||||
|
||||
let to_info = |v: &library::Video| VideoInfo {
|
||||
id: v.id.clone(),
|
||||
title: v.title.clone(),
|
||||
duration_secs: v.duration_secs,
|
||||
file_size: v.file_size,
|
||||
has_video: v.video_path.is_some(),
|
||||
has_live_chat: v.has_live_chat,
|
||||
watched: watched.contains(&v.id),
|
||||
};
|
||||
|
||||
ChannelInfo {
|
||||
name: ch.name.clone(),
|
||||
total_videos: ch.total_videos(),
|
||||
size_bytes,
|
||||
subscriber_count: ch.meta.as_ref().and_then(|m| m.subscriber_count),
|
||||
uploader: ch.meta.as_ref().and_then(|m| m.uploader.clone()),
|
||||
channel_url: ch.meta.as_ref().and_then(|m| m.channel_url.clone()),
|
||||
playlists: ch
|
||||
.playlists
|
||||
.iter()
|
||||
.map(|p| PlaylistInfo {
|
||||
name: p.name.clone(),
|
||||
videos: p.videos.iter().map(to_info).collect(),
|
||||
})
|
||||
.collect(),
|
||||
videos: ch.videos.iter().map(to_info).collect(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Json(LibraryResponse { channels })
|
||||
}
|
||||
|
||||
async fn post_download(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<StartDownloadRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let url = body.url.trim().to_string();
|
||||
if url.is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, "empty URL").into_response();
|
||||
}
|
||||
let kind = detect_url_kind(&url);
|
||||
{
|
||||
let mut dl = state.downloader.lock().unwrap();
|
||||
dl.start(url, &kind);
|
||||
}
|
||||
(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(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(video_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let db = match Database::open(&state.db_path) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
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) {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
|
||||
}
|
||||
if now_watched {
|
||||
watched.insert(video_id);
|
||||
} else {
|
||||
watched.remove(&video_id);
|
||||
}
|
||||
(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 {
|
||||
let root = state.channels_root.clone();
|
||||
let new_lib = library::scan_channels(&root);
|
||||
*state.library.lock().unwrap() = new_lib;
|
||||
(StatusCode::OK, "rescanned")
|
||||
}
|
||||
|
||||
// ── Server entry point ────────────────────────────────────────────────────────
|
||||
|
||||
pub fn run(config: Config) -> ! {
|
||||
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
||||
rt.block_on(async move {
|
||||
serve(config).await;
|
||||
});
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn serve(config: Config) {
|
||||
let channels_root = config.backup.directory.clone();
|
||||
let db_path = channels_root.join("yt-offline.db");
|
||||
let library = library::scan_channels(&channels_root);
|
||||
|
||||
let db = Database::open(&db_path).expect("web: open db");
|
||||
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 {
|
||||
library: Mutex::new(library),
|
||||
downloader: Mutex::new(downloader),
|
||||
watched: Mutex::new(watched),
|
||||
db_path,
|
||||
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()
|
||||
.route("/", get(get_index))
|
||||
.route("/api/library", get(get_library))
|
||||
.route("/api/download", post(post_download))
|
||||
.route("/api/progress", get(get_progress))
|
||||
.route("/api/events", get(get_events))
|
||||
.route("/api/watched/:id", post(post_watched))
|
||||
.route("/api/rescan", post(post_rescan))
|
||||
.with_state(state);
|
||||
|
||||
let port = config.web.port;
|
||||
let addr = format!("0.0.0.0:{port}");
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.expect("bind");
|
||||
println!("yt-offline web UI: http://localhost:{port}");
|
||||
axum::serve(listener, app).await.expect("serve");
|
||||
}
|
||||
|
||||
// ── Embedded HTML/JS UI ───────────────────────────────────────────────────────
|
||||
|
||||
const HTML_UI: &str = r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>yt-offline</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #1a1a2e; --panel: #16213e; --card: #0f3460;
|
||||
--accent: #e94560; --text: #eee; --muted: #aaa; --border: #334;
|
||||
}
|
||||
* { 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; }
|
||||
header { background: var(--panel); padding: 10px 16px; display: flex; gap: 12px; align-items: center; border-bottom: 1px solid var(--border); }
|
||||
header h1 { font-size: 1.1em; }
|
||||
header input { flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 6px 10px; border-radius: 4px; }
|
||||
button { background: var(--accent); color: #fff; border: none; padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||||
button:hover { opacity: 0.85; }
|
||||
button.muted { background: var(--card); }
|
||||
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 h3 { padding: 6px 12px; font-size: 0.75em; text-transform: uppercase; color: var(--muted); letter-spacing: 0.08em; }
|
||||
.ch-item { padding: 6px 12px; cursor: pointer; font-size: 13px; border-left: 3px solid transparent; }
|
||||
.ch-item:hover { background: var(--card); }
|
||||
.ch-item.active { border-left-color: var(--accent); background: var(--card); }
|
||||
.ch-sub { padding: 4px 12px 4px 24px; font-size: 12px; color: var(--muted); cursor: pointer; }
|
||||
.ch-sub:hover { color: var(--text); }
|
||||
section#content { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||
.video-card { background: var(--card); border-radius: 6px; overflow: hidden; cursor: pointer; border: 2px solid transparent; transition: border-color 0.15s; }
|
||||
.video-card:hover { border-color: var(--accent); }
|
||||
.video-card.watched { opacity: 0.55; }
|
||||
.thumb { width: 100%; aspect-ratio: 16/9; background: #222; display: flex; align-items: center; justify-content: center; font-size: 2em; color: #555; }
|
||||
.card-body { padding: 8px; }
|
||||
.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-meta { font-size: 11px; color: var(--muted); display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.card-actions { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.card-actions button { font-size: 11px; padding: 3px 8px; }
|
||||
#download-bar { background: var(--panel); border-top: 1px solid var(--border); padding: 10px 16px; display: flex; gap: 8px; align-items: center; }
|
||||
#download-bar input { flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 6px 10px; border-radius: 4px; }
|
||||
#jobs { background: var(--panel); border-top: 1px solid var(--border); }
|
||||
.job { padding: 6px 16px; display: flex; align-items: center; gap: 10px; font-size: 12px; border-bottom: 1px solid var(--border); }
|
||||
.job-state { font-weight: 700; min-width: 50px; }
|
||||
.job-state.running { color: #facc15; }
|
||||
.job-state.done { color: #4ade80; }
|
||||
.job-state.failed { color: #f87171; }
|
||||
progress { flex: 1; height: 6px; accent-color: var(--accent); }
|
||||
#status { font-size: 12px; color: var(--muted); padding: 0 16px; }
|
||||
.badge { background: var(--accent); color: #fff; border-radius: 10px; padding: 1px 7px; font-size: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>yt-offline</h1>
|
||||
<input type="search" id="search" placeholder="Filter by title or ID…" oninput="filterCards()">
|
||||
<button class="muted" onclick="rescan()">⟳ Rescan</button>
|
||||
<span id="status"></span>
|
||||
</header>
|
||||
<main>
|
||||
<aside id="sidebar"></aside>
|
||||
<section id="content"><div class="video-grid" id="grid"></div></section>
|
||||
</main>
|
||||
<div id="jobs"></div>
|
||||
<div id="download-bar">
|
||||
<input type="url" id="dl-url" placeholder="YouTube URL to download…" onkeydown="if(event.key==='Enter')startDownload()">
|
||||
<button onclick="startDownload()">⬇ Download</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let library = [];
|
||||
let activeChannel = null;
|
||||
let activePlaylist = null;
|
||||
|
||||
async function loadLibrary() {
|
||||
const r = await fetch('/api/library');
|
||||
library = (await r.json()).channels;
|
||||
renderSidebar();
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
function renderSidebar() {
|
||||
const el = document.getElementById('sidebar');
|
||||
let total = library.reduce((s, c) => s + c.total_videos, 0);
|
||||
let html = `<h3>Channels</h3>
|
||||
<div class="ch-item ${!activeChannel ? 'active' : ''}" onclick="selectChannel(null)">⊞ All (${total})</div>`;
|
||||
for (const ch of library) {
|
||||
const active = activeChannel === ch.name && !activePlaylist;
|
||||
html += `<div class="ch-item ${active ? 'active' : ''}" onclick="selectChannel('${esc(ch.name)}')">
|
||||
${esc(ch.name)} <span style="color:var(--muted);font-size:11px">(${ch.total_videos})</span>
|
||||
</div>`;
|
||||
if (activeChannel === ch.name && ch.playlists.length) {
|
||||
for (const pl of ch.playlists) {
|
||||
const ap = activePlaylist === pl.name;
|
||||
html += `<div class="ch-sub ${ap ? 'active' : ''}" onclick="selectPlaylist('${esc(ch.name)}','${esc(pl.name)}')">└ ${esc(pl.name)} (${pl.videos.length})</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function selectChannel(name) {
|
||||
activeChannel = name; activePlaylist = null;
|
||||
renderSidebar(); renderGrid();
|
||||
}
|
||||
function selectPlaylist(ch, pl) {
|
||||
activeChannel = ch; activePlaylist = pl;
|
||||
renderSidebar(); renderGrid();
|
||||
}
|
||||
|
||||
function currentVideos() {
|
||||
const q = document.getElementById('search').value.toLowerCase();
|
||||
let videos = [];
|
||||
for (const ch of library) {
|
||||
if (activeChannel && ch.name !== activeChannel) continue;
|
||||
const pool = activePlaylist
|
||||
? (ch.playlists.find(p => p.name === activePlaylist)?.videos || [])
|
||||
: [...ch.videos, ...ch.playlists.flatMap(p => p.videos)];
|
||||
for (const v of pool) {
|
||||
if (!q || v.title.toLowerCase().includes(q) || v.id.includes(q))
|
||||
videos.push({...v, channel: ch.name});
|
||||
}
|
||||
}
|
||||
return videos;
|
||||
}
|
||||
|
||||
function filterCards() { renderGrid(); }
|
||||
|
||||
function renderGrid() {
|
||||
const videos = currentVideos();
|
||||
document.getElementById('status').textContent = `${videos.length} videos`;
|
||||
const grid = document.getElementById('grid');
|
||||
grid.innerHTML = videos.map(v => `
|
||||
<div class="video-card ${v.watched ? 'watched' : ''}" id="card-${v.id}">
|
||||
<div class="thumb">▶</div>
|
||||
<div class="card-body">
|
||||
<div class="card-title">${esc(v.title)}</div>
|
||||
<div class="card-meta">
|
||||
<span>${esc(v.channel)}</span>
|
||||
${v.duration_secs ? `<span>${fmtDur(v.duration_secs)}</span>` : ''}
|
||||
${v.file_size ? `<span>${fmtSize(v.file_size)}</span>` : ''}
|
||||
${v.has_live_chat ? '<span>💬</span>' : ''}
|
||||
${!v.has_video ? '<span style="color:#f87171">no file</span>' : ''}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button onclick="toggleWatched('${v.id}')">${v.watched ? '✓ Watched' : '○ Watch'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
async function toggleWatched(id) {
|
||||
await fetch(`/api/watched/${id}`, {method:'POST'});
|
||||
await loadLibrary();
|
||||
}
|
||||
|
||||
async function startDownload() {
|
||||
const url = document.getElementById('dl-url').value.trim();
|
||||
if (!url) return;
|
||||
await fetch('/api/download', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({url})});
|
||||
document.getElementById('dl-url').value = '';
|
||||
}
|
||||
|
||||
async function rescan() {
|
||||
await fetch('/api/rescan', {method:'POST'});
|
||||
await loadLibrary();
|
||||
}
|
||||
|
||||
// SSE progress
|
||||
const es = new EventSource('/api/events');
|
||||
es.onmessage = e => {
|
||||
try {
|
||||
const jobs = JSON.parse(e.data);
|
||||
renderJobs(jobs);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
function renderJobs(jobs) {
|
||||
if (!jobs.length) { document.getElementById('jobs').innerHTML = ''; return; }
|
||||
document.getElementById('jobs').innerHTML = jobs.map(j => `
|
||||
<div class="job">
|
||||
<span class="job-state ${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>
|
||||
${j.state==='running' ? `<progress value="${j.progress}" max="1"></progress>` : ''}
|
||||
</div>`).join('');
|
||||
// Rescan library when all done
|
||||
if (jobs.length && jobs.every(j => j.state !== 'running')) loadLibrary();
|
||||
}
|
||||
|
||||
function fmtDur(s) {
|
||||
s = Math.floor(s);
|
||||
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)}`;
|
||||
}
|
||||
function fmtSize(b) {
|
||||
if (b>=1073741824) return (b/1073741824).toFixed(1)+' GB';
|
||||
if (b>=1048576) return Math.round(b/1048576)+' MB';
|
||||
return Math.round(b/1024)+' KB';
|
||||
}
|
||||
function p(n) { return String(n).padStart(2,'0'); }
|
||||
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
loadLibrary();
|
||||
</script>
|
||||
</body>
|
||||
</html>"#;
|
||||
Loading…
Add table
Add a link
Reference in a new issue