Add watched tracking, resume positions, sort, density slider, channel metadata, mpv IPC, browser selector, auto-rescan
- SQLite DB for watched/position persistence (database.rs) - Mark watched toggle on cards and detail panel - mpv IPC socket integration for resume position tracking (unix) - Sort by title, duration, file size - Thumbnail density slider in top bar - Channel metadata banner (subscriber count, uploader, channel URL) - Cookie browser selector dropdown in settings - Auto-rescan library when a download job finishes - Fix yt-dlp --no-progress-bar flag (does not exist; removed) - Browser field wired through config → downloader Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
95a73b0980
commit
5b3b8fc901
7 changed files with 644 additions and 221 deletions
546
src/app.rs
546
src/app.rs
|
|
@ -1,14 +1,35 @@
|
|||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::database::Database;
|
||||
use crate::downloader::{detect_url_kind, Downloader, JobState, UrlKind};
|
||||
use crate::library::{self, Video};
|
||||
use crate::theme;
|
||||
|
||||
const BROWSERS: &[(&str, &str)] = &[
|
||||
("firefox", "Firefox"),
|
||||
("chromium", "Chromium"),
|
||||
("chrome", "Chrome"),
|
||||
("opera", "Opera"),
|
||||
("brave", "Brave"),
|
||||
("vivaldi", "Vivaldi"),
|
||||
("none", "None (no cookies)"),
|
||||
];
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum SortMode {
|
||||
Title,
|
||||
DurationAsc,
|
||||
DurationDesc,
|
||||
SizeAsc,
|
||||
SizeDesc,
|
||||
}
|
||||
|
||||
struct Card {
|
||||
channel_name: String,
|
||||
title: String,
|
||||
|
|
@ -16,6 +37,10 @@ struct Card {
|
|||
video_path: Option<PathBuf>,
|
||||
thumb_path: Option<PathBuf>,
|
||||
has_live_chat: bool,
|
||||
duration_secs: Option<f64>,
|
||||
file_size: Option<u64>,
|
||||
watched: bool,
|
||||
resume_pos: Option<f64>,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
|
|
@ -36,6 +61,14 @@ pub struct App {
|
|||
desc_cache: HashMap<PathBuf, String>,
|
||||
status: String,
|
||||
settings_dir: String,
|
||||
db: Database,
|
||||
card_density: f32,
|
||||
sort_mode: SortMode,
|
||||
watched: HashSet<String>,
|
||||
resume_positions: HashMap<String, f64>,
|
||||
prev_any_running: bool,
|
||||
currently_playing: Option<String>,
|
||||
mpv_rx: Option<Receiver<(String, f64)>>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
|
|
@ -63,6 +96,14 @@ impl App {
|
|||
library.iter().map(|c| c.total_videos()).sum::<usize>()
|
||||
);
|
||||
|
||||
let db_path = channels_root.join("yt-offline.db");
|
||||
let db = Database::open(&db_path)
|
||||
.unwrap_or_else(|_| Database::open_in_memory().expect("in-memory db failed"));
|
||||
let watched = db.get_watched().unwrap_or_default();
|
||||
let resume_positions = db.get_positions().unwrap_or_default();
|
||||
|
||||
let browser = config.player.browser.clone();
|
||||
|
||||
Self {
|
||||
config,
|
||||
config_path,
|
||||
|
|
@ -72,7 +113,7 @@ impl App {
|
|||
selected_playlist: None,
|
||||
selected_video: None,
|
||||
search: String::new(),
|
||||
downloader: Downloader::new(channels_root),
|
||||
downloader: Downloader::new(channels_root, browser),
|
||||
show_downloads: false,
|
||||
show_settings: false,
|
||||
dl_url: String::new(),
|
||||
|
|
@ -81,6 +122,14 @@ impl App {
|
|||
desc_cache: HashMap::new(),
|
||||
status,
|
||||
settings_dir,
|
||||
db,
|
||||
card_density: 1.0,
|
||||
sort_mode: SortMode::Title,
|
||||
watched,
|
||||
resume_positions,
|
||||
prev_any_running: false,
|
||||
currently_playing: None,
|
||||
mpv_rx: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -116,48 +165,58 @@ impl App {
|
|||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(pi) = playlist_filter {
|
||||
let videos_iter: Box<dyn Iterator<Item = &library::Video>> = if let Some(pi) = playlist_filter {
|
||||
let Some(playlist) = channel.playlists.get(pi) else { continue };
|
||||
for v in &playlist.videos {
|
||||
if !query.is_empty()
|
||||
&& !v.title.to_lowercase().contains(&query)
|
||||
&& !v.id.to_lowercase().contains(&query)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
Box::new(playlist.videos.iter())
|
||||
} else {
|
||||
let all_videos: Vec<&library::Video> = channel
|
||||
.videos
|
||||
.iter()
|
||||
.chain(channel.playlists.iter().flat_map(|p| p.videos.iter()))
|
||||
.collect();
|
||||
for v in all_videos {
|
||||
if !query.is_empty()
|
||||
&& !v.title.to_lowercase().contains(&query)
|
||||
&& !v.id.to_lowercase().contains(&query)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
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,
|
||||
});
|
||||
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;
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
match self.sort_mode {
|
||||
SortMode::Title => cards.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())),
|
||||
SortMode::DurationAsc => cards.sort_by(|a, b| {
|
||||
a.duration_secs.unwrap_or(0.0)
|
||||
.partial_cmp(&b.duration_secs.unwrap_or(0.0))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
}),
|
||||
SortMode::DurationDesc => cards.sort_by(|a, b| {
|
||||
b.duration_secs.unwrap_or(0.0)
|
||||
.partial_cmp(&a.duration_secs.unwrap_or(0.0))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
}),
|
||||
SortMode::SizeAsc => cards.sort_by_key(|c| c.file_size.unwrap_or(0)),
|
||||
SortMode::SizeDesc => {
|
||||
cards.sort_by_key(|c| c.file_size.unwrap_or(0));
|
||||
cards.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
cards
|
||||
}
|
||||
|
||||
|
|
@ -202,24 +261,79 @@ impl App {
|
|||
text
|
||||
}
|
||||
|
||||
fn play(&mut self, path: &Path) {
|
||||
fn play_with_tracking(&mut self, path: &Path, video_id: String) {
|
||||
let cmd = self.config.player.command.clone();
|
||||
match Command::new(&cmd).arg(path).spawn() {
|
||||
Ok(_) => self.status = format!("Playing {}", file_label(path)),
|
||||
Err(_) => match Command::new("xdg-open").arg(path).spawn() {
|
||||
Ok(_) => self.status = format!("Opened {} in default player", file_label(path)),
|
||||
Err(e) => self.status = format!("Couldn't open {}: {e}", file_label(path)),
|
||||
},
|
||||
let use_mpv_ipc = cmd.contains("mpv");
|
||||
|
||||
#[cfg(unix)]
|
||||
let sock_path = format!("/tmp/yt-offline-{video_id}.sock");
|
||||
|
||||
let mut child_cmd = Command::new(&cmd);
|
||||
|
||||
#[cfg(unix)]
|
||||
if use_mpv_ipc {
|
||||
child_cmd.arg(format!("--input-ipc-server={sock_path}"));
|
||||
}
|
||||
|
||||
if let Some(&pos) = self.resume_positions.get(&video_id) {
|
||||
if pos > 5.0 && use_mpv_ipc {
|
||||
child_cmd.arg(format!("--start={}", pos as u64));
|
||||
}
|
||||
}
|
||||
|
||||
child_cmd.arg(path);
|
||||
|
||||
match child_cmd.spawn() {
|
||||
Ok(_) => {
|
||||
self.status = format!("Playing {}", file_label(path));
|
||||
self.currently_playing = Some(video_id.clone());
|
||||
|
||||
#[cfg(unix)]
|
||||
if use_mpv_ipc {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
self.mpv_rx = Some(rx);
|
||||
let id = video_id.clone();
|
||||
std::thread::spawn(move || {
|
||||
spawn_mpv_tracker(sock_path, id, tx);
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
#[cfg(target_os = "macos")]
|
||||
let fallback = "open";
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let fallback = "xdg-open";
|
||||
|
||||
match Command::new(fallback).arg(path).spawn() {
|
||||
Ok(_) => self.status = format!("Opened {} in default player", file_label(path)),
|
||||
Err(e) => self.status = format!("Couldn't open {}: {e}", file_label(path)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open_in_file_manager(&mut self, path: &Path) {
|
||||
let target = if path.is_dir() { path } else { path.parent().unwrap_or(path) };
|
||||
if let Err(e) = Command::new("xdg-open").arg(target).spawn() {
|
||||
#[cfg(target_os = "macos")]
|
||||
let cmd = "open";
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let cmd = "xdg-open";
|
||||
if let Err(e) = Command::new(cmd).arg(target).spawn() {
|
||||
self.status = format!("Couldn't open folder: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_watched(&mut self, video_id: &str) {
|
||||
let now_watched = !self.watched.contains(video_id);
|
||||
if let Ok(()) = self.db.set_watched(video_id, now_watched) {
|
||||
if now_watched {
|
||||
self.watched.insert(video_id.to_string());
|
||||
} else {
|
||||
self.watched.remove(video_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn top_bar(&mut self, ctx: &egui::Context) {
|
||||
egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
|
||||
ui.add_space(2.0);
|
||||
|
|
@ -230,12 +344,19 @@ impl App {
|
|||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.search)
|
||||
.hint_text("filter by title or id")
|
||||
.desired_width(260.0),
|
||||
.desired_width(200.0),
|
||||
);
|
||||
if !self.search.is_empty() && ui.button("✖").on_hover_text("clear").clicked() {
|
||||
self.search.clear();
|
||||
}
|
||||
ui.separator();
|
||||
ui.label("Size:");
|
||||
ui.add(
|
||||
egui::Slider::new(&mut self.card_density, 0.5_f32..=2.0_f32)
|
||||
.show_value(false)
|
||||
.step_by(0.1),
|
||||
);
|
||||
ui.separator();
|
||||
if ui.button("⟳ Rescan").clicked() {
|
||||
self.rescan();
|
||||
}
|
||||
|
|
@ -303,8 +424,7 @@ impl App {
|
|||
(pl.name.clone(), pl.videos.len())
|
||||
};
|
||||
let is_pl = self.selected_playlist == Some((i, pi));
|
||||
let pl_label =
|
||||
format!(" └ {} ({})", pl_name, pl_len);
|
||||
let pl_label = format!(" └ {} ({})", pl_name, pl_len);
|
||||
if ui.selectable_label(is_pl, pl_label).clicked() {
|
||||
self.selected_playlist = Some((i, pi));
|
||||
self.selected_video = None;
|
||||
|
|
@ -417,7 +537,7 @@ impl App {
|
|||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(440.0)
|
||||
.default_width(460.0)
|
||||
.show(ctx, |ui| {
|
||||
egui::Grid::new("settings_grid")
|
||||
.num_columns(2)
|
||||
|
|
@ -427,7 +547,7 @@ impl App {
|
|||
ui.label("Backup directory:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.settings_dir)
|
||||
.desired_width(280.0)
|
||||
.desired_width(300.0)
|
||||
.hint_text("/path/to/channels"),
|
||||
);
|
||||
ui.end_row();
|
||||
|
|
@ -435,11 +555,33 @@ impl App {
|
|||
ui.label("Player command:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.config.player.command)
|
||||
.desired_width(280.0)
|
||||
.desired_width(300.0)
|
||||
.hint_text("mpv"),
|
||||
);
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Cookie browser:");
|
||||
let current_browser = self.config.player.browser.clone();
|
||||
let display = BROWSERS
|
||||
.iter()
|
||||
.find(|(id, _)| *id == current_browser)
|
||||
.map(|(_, label)| *label)
|
||||
.unwrap_or(current_browser.as_str());
|
||||
egui::ComboBox::from_id_salt("browser_combo")
|
||||
.selected_text(display)
|
||||
.show_ui(ui, |ui| {
|
||||
for (id, label) in BROWSERS {
|
||||
if ui
|
||||
.selectable_label(self.config.player.browser == *id, *label)
|
||||
.clicked()
|
||||
{
|
||||
self.config.player.browser = id.to_string();
|
||||
self.downloader.browser = id.to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Theme:");
|
||||
egui::ComboBox::from_id_salt("theme_combo")
|
||||
.selected_text(
|
||||
|
|
@ -483,7 +625,11 @@ impl App {
|
|||
self.rescan();
|
||||
}
|
||||
}
|
||||
ui.label(egui::RichText::new("Theme previews immediately; other changes apply on save.").weak().small());
|
||||
ui.label(
|
||||
egui::RichText::new("Theme previews immediately; other changes apply on save.")
|
||||
.weak()
|
||||
.small(),
|
||||
);
|
||||
});
|
||||
});
|
||||
self.show_settings = open;
|
||||
|
|
@ -498,6 +644,10 @@ impl App {
|
|||
self.selected_video = None;
|
||||
return;
|
||||
};
|
||||
|
||||
let is_watched = self.watched.contains(&selected_id);
|
||||
let resume_pos = self.resume_positions.get(&selected_id).copied();
|
||||
|
||||
egui::TopBottomPanel::bottom("detail")
|
||||
.resizable(true)
|
||||
.default_height(220.0)
|
||||
|
|
@ -506,29 +656,68 @@ impl App {
|
|||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&video.title);
|
||||
if video.video_path.is_some() && ui.button("▶ Play").clicked() {
|
||||
if let Some(p) = video.video_path.clone() {
|
||||
self.play(&p);
|
||||
|
||||
if video.video_path.is_some() {
|
||||
if ui.button("▶ Play").clicked() {
|
||||
if let Some(p) = video.video_path.clone() {
|
||||
self.play_with_tracking(&p, selected_id.clone());
|
||||
}
|
||||
}
|
||||
if let Some(pos) = resume_pos {
|
||||
if pos > 5.0 {
|
||||
let label = format!("⏩ Resume ({})", format_duration(pos));
|
||||
if ui.button(label).clicked() {
|
||||
if let Some(p) = video.video_path.clone() {
|
||||
self.play_with_tracking(&p, selected_id.clone());
|
||||
}
|
||||
}
|
||||
if ui.small_button("✖ clear position").clicked() {
|
||||
let _ = self.db.clear_position(&selected_id);
|
||||
self.resume_positions.remove(&selected_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(p) = video.video_path.clone() {
|
||||
if ui.button("📁 Show file").clicked() {
|
||||
self.open_in_file_manager(&p);
|
||||
}
|
||||
}
|
||||
if video.has_live_chat {
|
||||
ui.label(egui::RichText::new("💬 has live chat").small().weak());
|
||||
|
||||
let watched_label = if is_watched { "✓ Watched" } else { "○ Mark watched" };
|
||||
if ui.button(watched_label).clicked() {
|
||||
self.toggle_watched(&selected_id);
|
||||
}
|
||||
|
||||
if video.has_live_chat {
|
||||
ui.label(egui::RichText::new("💬 live chat").small().weak());
|
||||
}
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("id: {}", video.id)).monospace().weak(),
|
||||
);
|
||||
if ui.button("✖ close").clicked() {
|
||||
self.selected_video = None;
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new(format!("id: {}", video.id))
|
||||
.monospace()
|
||||
.weak(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(secs) = video.duration_secs {
|
||||
ui.label(egui::RichText::new(format_duration(secs)).small().weak());
|
||||
ui.label(egui::RichText::new("·").weak());
|
||||
}
|
||||
if let Some(bytes) = video.file_size {
|
||||
ui.label(egui::RichText::new(format_size(bytes)).small().weak());
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
let description = self.description(&video);
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
|
|
@ -541,6 +730,32 @@ 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();
|
||||
|
||||
// Channel metadata banner
|
||||
if let Some(ci) = self.selected_channel {
|
||||
if let Some(ch) = self.library.get(ci) {
|
||||
if let Some(meta) = &ch.meta {
|
||||
ui.group(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(name) = &meta.uploader {
|
||||
ui.strong(name);
|
||||
}
|
||||
if let Some(subs) = meta.subscriber_count {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{} subscribers", format_subs(subs)))
|
||||
.weak(),
|
||||
);
|
||||
}
|
||||
if let Some(url) = &meta.channel_url {
|
||||
ui.hyperlink_to("Open on YouTube", url);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort controls
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("{} videos", cards.len()));
|
||||
if !self.search.trim().is_empty() {
|
||||
|
|
@ -548,8 +763,18 @@ impl App {
|
|||
egui::RichText::new(format!("(filtered by \"{}\")", self.search.trim())).weak(),
|
||||
);
|
||||
}
|
||||
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 ↑");
|
||||
ui.selectable_value(&mut self.sort_mode, SortMode::DurationDesc, "Dur ↓");
|
||||
ui.selectable_value(&mut self.sort_mode, SortMode::DurationAsc, "Dur ↑");
|
||||
ui.selectable_value(&mut self.sort_mode, SortMode::Title, "Title");
|
||||
ui.label(egui::RichText::new("Sort:").weak());
|
||||
});
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
if cards.is_empty() {
|
||||
ui.add_space(20.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
|
|
@ -564,19 +789,23 @@ impl App {
|
|||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let density = self.card_density;
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
let thumb_size = egui::vec2(176.0, 99.0);
|
||||
let thumb_w = (176.0 * density).round();
|
||||
let thumb_h = (99.0 * density).round();
|
||||
let thumb_size = egui::vec2(thumb_w, thumb_h);
|
||||
|
||||
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 mut clicked_card = false;
|
||||
let mut play_card = false;
|
||||
let mut toggle_watched_card = false;
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let (rect, resp) =
|
||||
ui.allocate_exact_size(thumb_size, egui::Sense::click());
|
||||
let texture = card
|
||||
.thumb_path
|
||||
.as_ref()
|
||||
.and_then(|p| self.texture(ctx, p));
|
||||
let (rect, resp) = ui.allocate_exact_size(thumb_size, egui::Sense::click());
|
||||
let texture = card.thumb_path.as_ref().and_then(|p| self.texture(ctx, p));
|
||||
match &texture {
|
||||
Some(handle) => {
|
||||
egui::Image::new(handle)
|
||||
|
|
@ -584,20 +813,17 @@ impl App {
|
|||
.paint_at(ui, rect);
|
||||
}
|
||||
None => {
|
||||
ui.painter().rect_filled(
|
||||
rect,
|
||||
4.0,
|
||||
egui::Color32::from_gray(38),
|
||||
);
|
||||
ui.painter().rect_filled(rect, 4.0, egui::Color32::from_gray(38));
|
||||
ui.painter().text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"▶",
|
||||
egui::FontId::proportional(26.0),
|
||||
egui::FontId::proportional(26.0 * density),
|
||||
egui::Color32::from_gray(110),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if selected {
|
||||
ui.painter().rect_stroke(
|
||||
rect,
|
||||
|
|
@ -605,6 +831,32 @@ impl App {
|
|||
egui::Stroke::new(2.0, egui::Color32::from_rgb(120, 170, 230)),
|
||||
);
|
||||
}
|
||||
if is_playing {
|
||||
ui.painter().rect_stroke(
|
||||
rect,
|
||||
4.0,
|
||||
egui::Stroke::new(2.0, egui::Color32::from_rgb(110, 200, 110)),
|
||||
);
|
||||
}
|
||||
// Watched overlay
|
||||
if card.watched {
|
||||
ui.painter().rect_filled(
|
||||
egui::Rect::from_min_size(
|
||||
rect.min,
|
||||
egui::vec2(rect.width(), rect.height() * 0.18),
|
||||
),
|
||||
0.0,
|
||||
egui::Color32::from_rgba_premultiplied(30, 140, 60, 200),
|
||||
);
|
||||
ui.painter().text(
|
||||
rect.min + egui::vec2(4.0, 2.0),
|
||||
egui::Align2::LEFT_TOP,
|
||||
"✓ watched",
|
||||
egui::FontId::proportional(10.0 * density),
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
}
|
||||
|
||||
if resp.clicked() {
|
||||
clicked_card = true;
|
||||
}
|
||||
|
|
@ -613,10 +865,17 @@ impl App {
|
|||
}
|
||||
|
||||
ui.vertical(|ui| {
|
||||
let title_color = if card.watched {
|
||||
egui::Color32::from_gray(140)
|
||||
} else {
|
||||
ui.visuals().text_color()
|
||||
};
|
||||
if ui
|
||||
.selectable_label(
|
||||
selected,
|
||||
egui::RichText::new(&card.title).strong(),
|
||||
egui::RichText::new(&card.title)
|
||||
.strong()
|
||||
.color(title_color),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
|
|
@ -624,12 +883,18 @@ impl App {
|
|||
}
|
||||
ui.horizontal(|ui| {
|
||||
if show_channel {
|
||||
ui.label(
|
||||
egui::RichText::new(&card.channel_name).small().weak(),
|
||||
);
|
||||
ui.label(egui::RichText::new(&card.channel_name).small().weak());
|
||||
ui.label(egui::RichText::new("·").weak());
|
||||
}
|
||||
ui.label(egui::RichText::new(&card.id).small().monospace().weak());
|
||||
if let Some(secs) = card.duration_secs {
|
||||
ui.label(egui::RichText::new("·").weak());
|
||||
ui.label(egui::RichText::new(format_duration(secs)).small().weak());
|
||||
}
|
||||
if let Some(bytes) = card.file_size {
|
||||
ui.label(egui::RichText::new("·").weak());
|
||||
ui.label(egui::RichText::new(format_size(bytes)).small().weak());
|
||||
}
|
||||
if card.has_live_chat {
|
||||
ui.label(egui::RichText::new("· 💬").small().weak());
|
||||
}
|
||||
|
|
@ -645,20 +910,36 @@ impl App {
|
|||
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() {
|
||||
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 let Some(p) = card.video_path.clone() {
|
||||
self.play(&p);
|
||||
let id = card.id.clone();
|
||||
self.play_with_tracking(&p, id);
|
||||
}
|
||||
self.selected_video = Some(card.id.clone());
|
||||
} else if clicked_card {
|
||||
self.selected_video = Some(card.id.clone());
|
||||
}
|
||||
if toggle_watched_card {
|
||||
let id = card.id.clone();
|
||||
self.toggle_watched(&id);
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
}
|
||||
});
|
||||
|
|
@ -668,9 +949,25 @@ impl App {
|
|||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.downloader.poll();
|
||||
if self.downloader.any_running() {
|
||||
|
||||
let any_running = self.downloader.any_running();
|
||||
if self.prev_any_running && !any_running && !self.downloader.jobs.is_empty() {
|
||||
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
|
||||
if let Some(rx) = &self.mpv_rx {
|
||||
while let Ok((video_id, pos)) = rx.try_recv() {
|
||||
let _ = self.db.set_position(&video_id, pos);
|
||||
self.resume_positions.insert(video_id, pos);
|
||||
}
|
||||
}
|
||||
|
||||
self.decode_budget = 6;
|
||||
|
||||
self.top_bar(ctx);
|
||||
|
|
@ -686,6 +983,61 @@ impl eframe::App for App {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn spawn_mpv_tracker(sock_path: String, video_id: String, tx: std::sync::mpsc::Sender<(String, f64)>) {
|
||||
use std::io::{Read, Write};
|
||||
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));
|
||||
if let Ok(s) = UnixStream::connect(&sock_path) {
|
||||
stream = Some(s);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut stream = match stream {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
|
||||
stream.set_read_timeout(Some(Duration::from_secs(3))).ok();
|
||||
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
let query = b"{\"command\":[\"get_property\",\"time-pos\"]}\n";
|
||||
if stream.write_all(query).is_err() {
|
||||
break;
|
||||
}
|
||||
match stream.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
for chunk in buf[..n].split(|&b| b == b'\n').filter(|c| !c.is_empty()) {
|
||||
if let Ok(s) = std::str::from_utf8(chunk) {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(s) {
|
||||
if let Some(pos) = val.get("data").and_then(|v| v.as_f64()) {
|
||||
if tx.send((video_id.clone(), pos)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e)
|
||||
if e.kind() == std::io::ErrorKind::WouldBlock
|
||||
|| e.kind() == std::io::ErrorKind::TimedOut =>
|
||||
{
|
||||
// no response yet, mpv may be paused — keep polling
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_thumbnail(ctx: &egui::Context, path: &Path) -> Option<egui::TextureHandle> {
|
||||
let image = image::open(path).ok()?;
|
||||
let image = image.thumbnail(384, 216);
|
||||
|
|
@ -700,3 +1052,35 @@ fn file_label(path: &Path) -> String {
|
|||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
fn format_duration(secs: f64) -> String {
|
||||
let secs = secs as u64;
|
||||
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}")
|
||||
}
|
||||
}
|
||||
|
||||
fn format_size(bytes: u64) -> String {
|
||||
if bytes >= 1_073_741_824 {
|
||||
format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
|
||||
} else if bytes >= 1_048_576 {
|
||||
format!("{:.0} MB", bytes as f64 / 1_048_576.0)
|
||||
} else {
|
||||
format!("{:.0} KB", bytes as f64 / 1_024.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_subs(n: u64) -> String {
|
||||
if n >= 1_000_000 {
|
||||
format!("{:.1}M", n as f64 / 1_000_000.0)
|
||||
} else if n >= 1_000 {
|
||||
format!("{:.1}K", n as f64 / 1_000.0)
|
||||
} else {
|
||||
n.to_string()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,18 @@ pub struct BackupSection {
|
|||
pub directory: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PlayerSection {
|
||||
#[serde(default = "default_player")]
|
||||
pub command: String,
|
||||
#[serde(default = "default_browser")]
|
||||
pub browser: String,
|
||||
}
|
||||
|
||||
impl Default for PlayerSection {
|
||||
fn default() -> Self {
|
||||
Self { command: default_player(), browser: default_browser() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
|
|
@ -33,13 +41,9 @@ impl Default for UiSection {
|
|||
}
|
||||
}
|
||||
|
||||
fn default_player() -> String {
|
||||
"mpv".to_string()
|
||||
}
|
||||
|
||||
fn default_theme() -> String {
|
||||
"dark".to_string()
|
||||
}
|
||||
fn default_player() -> String { "mpv".to_string() }
|
||||
fn default_browser() -> String { "firefox".to_string() }
|
||||
fn default_theme() -> String { "dark".to_string() }
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use rusqlite::{Connection, Result};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
pub struct Database {
|
||||
|
|
@ -13,39 +14,69 @@ impl Database {
|
|||
Ok(db)
|
||||
}
|
||||
|
||||
pub fn open_in_memory() -> Result<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
let db = Database { conn };
|
||||
db.init_schema()?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
fn init_schema(&self) -> Result<()> {
|
||||
self.conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS videos (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
downloaded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
"CREATE TABLE IF NOT EXISTS watched (
|
||||
video_id TEXT PRIMARY KEY,
|
||||
watched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS downloads (
|
||||
id INTEGER PRIMARY KEY,
|
||||
url TEXT NOT NULL,
|
||||
directory TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at DATETIME
|
||||
CREATE TABLE IF NOT EXISTS positions (
|
||||
video_id TEXT PRIMARY KEY,
|
||||
position_secs REAL NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn record_download(&self, url: &str, dir: &str, status: &str) -> Result<()> {
|
||||
pub fn set_watched(&self, video_id: &str, watched: bool) -> Result<()> {
|
||||
if watched {
|
||||
self.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])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_watched(&self) -> Result<HashSet<String>> {
|
||||
let mut stmt = self.conn.prepare("SELECT video_id FROM watched")?;
|
||||
let ids = stmt
|
||||
.query_map([], |row| row.get(0))?
|
||||
.filter_map(Result::ok)
|
||||
.collect();
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
pub fn set_position(&self, video_id: &str, position_secs: f64) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO downloads (url, directory, status) VALUES (?1, ?2, ?3)",
|
||||
[url, dir, status],
|
||||
"INSERT OR REPLACE INTO positions (video_id, position_secs, updated_at)
|
||||
VALUES (?1, ?2, CURRENT_TIMESTAMP)",
|
||||
rusqlite::params![video_id, position_secs],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_download_status(&self, id: i64, status: &str) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE downloads SET status = ?1, completed_at = CURRENT_TIMESTAMP WHERE id = ?2",
|
||||
[status, &id.to_string()],
|
||||
)?;
|
||||
pub fn clear_position(&self, video_id: &str) -> Result<()> {
|
||||
self.conn.execute("DELETE FROM positions WHERE video_id = ?1", [video_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_positions(&self) -> Result<HashMap<String, f64>> {
|
||||
let mut stmt = self.conn.prepare("SELECT video_id, position_secs FROM positions")?;
|
||||
let map = stmt
|
||||
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?)))?
|
||||
.filter_map(Result::ok)
|
||||
.collect();
|
||||
Ok(map)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ pub enum UrlKind {
|
|||
Unknown,
|
||||
}
|
||||
|
||||
/// Detects the kind of YouTube URL from a string.
|
||||
pub fn detect_url_kind(url: &str) -> UrlKind {
|
||||
if url.contains("playlist?list=") {
|
||||
return UrlKind::Playlist;
|
||||
|
|
@ -36,9 +35,7 @@ pub fn detect_url_kind(url: &str) -> UrlKind {
|
|||
fn extract_after<'a>(url: &'a str, marker: &str) -> Option<&'a str> {
|
||||
let start = url.find(marker)? + marker.len();
|
||||
let rest = &url[start..];
|
||||
let end = rest
|
||||
.find(|c| c == '/' || c == '?' || c == '&' || c == '#')
|
||||
.unwrap_or(rest.len());
|
||||
let end = rest.find(|c| c == '/' || c == '?' || c == '&' || c == '#').unwrap_or(rest.len());
|
||||
if end == 0 { None } else { Some(&rest[..end]) }
|
||||
}
|
||||
|
||||
|
|
@ -87,16 +84,18 @@ impl Job {
|
|||
pub struct Downloader {
|
||||
pub jobs: Vec<Job>,
|
||||
pub channels_root: PathBuf,
|
||||
pub browser: String,
|
||||
}
|
||||
|
||||
impl Downloader {
|
||||
pub fn new(channels_root: PathBuf) -> Self {
|
||||
Self { jobs: Vec::new(), channels_root }
|
||||
pub fn new(channels_root: PathBuf, browser: String) -> Self {
|
||||
Self { jobs: Vec::new(), channels_root, browser }
|
||||
}
|
||||
|
||||
pub fn start(&mut self, url: String, kind: &UrlKind) {
|
||||
let (tx, rx) = channel();
|
||||
let archive_path = self.channels_root.join("archive.txt");
|
||||
let browser = self.browser.clone();
|
||||
|
||||
let (out_arg, label) = match kind {
|
||||
UrlKind::Channel { handle } => {
|
||||
|
|
@ -119,31 +118,33 @@ impl Downloader {
|
|||
|
||||
let url_for_thread = url.clone();
|
||||
thread::spawn(move || {
|
||||
let spawn_result = Command::new("yt-dlp")
|
||||
.arg("--newline")
|
||||
let mut cmd = Command::new("yt-dlp");
|
||||
cmd.arg("--newline")
|
||||
.arg("--no-color")
|
||||
.arg("--no-progress-bar")
|
||||
.arg("--write-subs")
|
||||
.arg("--write-thumbnail")
|
||||
.arg("--write-description")
|
||||
.arg("--write-info-json")
|
||||
.arg("-f")
|
||||
.arg("mkv")
|
||||
.arg("--embed-metadata")
|
||||
.arg("--break-on-existing")
|
||||
.arg("--cookies-from-browser")
|
||||
.arg("firefox")
|
||||
.arg("--download-archive")
|
||||
.arg(archive_path.display().to_string())
|
||||
.arg("--ignore-errors")
|
||||
.arg("-o")
|
||||
.arg(&out_arg)
|
||||
.arg(&url_for_thread)
|
||||
.arg(&out_arg);
|
||||
|
||||
if !browser.is_empty() && browser != "none" {
|
||||
cmd.arg("--cookies-from-browser").arg(&browser);
|
||||
}
|
||||
|
||||
cmd.arg(&url_for_thread)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = match spawn_result {
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(err) => {
|
||||
let _ = tx.send(Msg::Line(format!("could not launch yt-dlp: {err}")));
|
||||
|
|
@ -174,14 +175,7 @@ impl Downloader {
|
|||
let _ = tx.send(Msg::Finished(ok));
|
||||
});
|
||||
|
||||
self.jobs.push(Job {
|
||||
url,
|
||||
label,
|
||||
state: JobState::Running,
|
||||
progress: 0.0,
|
||||
log: Vec::new(),
|
||||
rx,
|
||||
});
|
||||
self.jobs.push(Job { url, label, state: JobState::Running, progress: 0.0, log: Vec::new(), rx });
|
||||
}
|
||||
|
||||
pub fn poll(&mut self) {
|
||||
|
|
|
|||
184
src/library.rs
184
src/library.rs
|
|
@ -1,8 +1,4 @@
|
|||
//! Scanning the `channels/` directory tree into channels, playlists, and videos.
|
||||
//!
|
||||
//! yt-dlp's default output template produces files named `Title [VIDEOID].ext`,
|
||||
//! so every file that belongs to one video shares the stem `Title [VIDEOID]`.
|
||||
//! We group files by that stem.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -20,21 +16,32 @@ pub struct Video {
|
|||
pub thumb_path: Option<PathBuf>,
|
||||
pub description_path: Option<PathBuf>,
|
||||
pub has_live_chat: bool,
|
||||
pub duration_secs: Option<f64>,
|
||||
pub file_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Playlist {
|
||||
pub name: String,
|
||||
#[allow(dead_code)]
|
||||
pub path: PathBuf,
|
||||
pub videos: Vec<Video>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChannelMeta {
|
||||
pub subscriber_count: Option<u64>,
|
||||
pub channel_url: Option<String>,
|
||||
pub uploader: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Channel {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub videos: Vec<Video>,
|
||||
pub playlists: Vec<Playlist>,
|
||||
pub meta: Option<ChannelMeta>,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
|
|
@ -45,27 +52,44 @@ impl Channel {
|
|||
|
||||
pub fn scan_channels(root: &Path) -> Vec<Channel> {
|
||||
let mut channels = Vec::new();
|
||||
let Ok(entries) = std::fs::read_dir(root) else {
|
||||
return channels;
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(root) else { return channels };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if !path.is_dir() { continue; }
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let (videos, playlists) = scan_channel_dir(&path);
|
||||
channels.push(Channel { name, path, videos, playlists });
|
||||
let meta = load_channel_meta(&videos);
|
||||
channels.push(Channel { name, path, videos, playlists, meta });
|
||||
}
|
||||
channels.sort_by_key(|c| c.name.to_lowercase());
|
||||
channels
|
||||
}
|
||||
|
||||
fn load_channel_meta(videos: &[Video]) -> Option<ChannelMeta> {
|
||||
// Pull channel-level fields out of the first video's info.json
|
||||
let info_path = videos.iter().find_map(|v| {
|
||||
let p = v.video_path.as_ref()?.with_extension("info.json");
|
||||
p.exists().then_some(p)
|
||||
})?;
|
||||
let text = std::fs::read_to_string(&info_path).ok()?;
|
||||
let val: serde_json::Value = serde_json::from_str(&text).ok()?;
|
||||
Some(ChannelMeta {
|
||||
subscriber_count: val.get("channel_follower_count").and_then(|v| v.as_u64()),
|
||||
channel_url: val.get("channel_url").and_then(|v| v.as_str()).map(String::from),
|
||||
uploader: val
|
||||
.get("uploader")
|
||||
.or_else(|| val.get("channel"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
})
|
||||
}
|
||||
|
||||
enum FileKind {
|
||||
Video,
|
||||
Thumb,
|
||||
Description,
|
||||
LiveChat,
|
||||
Info,
|
||||
Other,
|
||||
}
|
||||
|
||||
|
|
@ -74,13 +98,11 @@ fn classify(file_name: &str) -> Option<(&str, FileKind)> {
|
|||
return Some((stem, FileKind::LiveChat));
|
||||
}
|
||||
if let Some(stem) = file_name.strip_suffix(".info.json") {
|
||||
return Some((stem, FileKind::Other));
|
||||
return Some((stem, FileKind::Info));
|
||||
}
|
||||
let dot = file_name.rfind('.')?;
|
||||
let stem = &file_name[..dot];
|
||||
if stem.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if stem.is_empty() { return None; }
|
||||
let ext = file_name[dot + 1..].to_lowercase();
|
||||
let kind = if VIDEO_EXTS.contains(&ext.as_str()) {
|
||||
FileKind::Video
|
||||
|
|
@ -98,65 +120,87 @@ fn parse_stem(stem: &str) -> Option<(String, String)> {
|
|||
let close = stem.rfind(']')?;
|
||||
let open = stem[..close].rfind('[')?;
|
||||
let id = stem[open + 1..close].trim();
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if id.is_empty() { return None; }
|
||||
let title = stem[..open].trim().trim_end_matches('-').trim();
|
||||
let title = if title.is_empty() { stem } else { title };
|
||||
Some((title.to_string(), id.to_string()))
|
||||
}
|
||||
|
||||
pub fn scan_video_files(dir: &Path) -> Vec<Video> {
|
||||
let mut by_stem: BTreeMap<String, Video> = BTreeMap::new();
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
struct RawVideo {
|
||||
id: String,
|
||||
title: String,
|
||||
stem: String,
|
||||
video_path: Option<PathBuf>,
|
||||
thumb_path: Option<PathBuf>,
|
||||
description_path: Option<PathBuf>,
|
||||
info_path: Option<PathBuf>,
|
||||
has_live_chat: bool,
|
||||
}
|
||||
|
||||
fn collect_raw_videos(entries: impl Iterator<Item = std::fs::DirEntry>) -> Vec<RawVideo> {
|
||||
let mut by_stem: BTreeMap<String, RawVideo> = BTreeMap::new();
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if !path.is_file() { continue; }
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
let Some((stem, kind)) = classify(&file_name) else {
|
||||
continue;
|
||||
};
|
||||
let Some((title, id)) = parse_stem(stem) else {
|
||||
continue;
|
||||
};
|
||||
let video = by_stem.entry(stem.to_string()).or_insert_with(|| Video {
|
||||
let Some((stem, kind)) = classify(&file_name) else { continue };
|
||||
let Some((title, id)) = parse_stem(stem) else { continue };
|
||||
let raw = by_stem.entry(stem.to_string()).or_insert_with(|| RawVideo {
|
||||
id,
|
||||
title,
|
||||
stem: stem.to_string(),
|
||||
video_path: None,
|
||||
thumb_path: None,
|
||||
description_path: None,
|
||||
info_path: None,
|
||||
has_live_chat: false,
|
||||
});
|
||||
match kind {
|
||||
FileKind::Video => {
|
||||
if video.video_path.is_none() {
|
||||
video.video_path = Some(path);
|
||||
}
|
||||
}
|
||||
FileKind::Thumb => {
|
||||
if video.thumb_path.is_none() {
|
||||
video.thumb_path = Some(path);
|
||||
}
|
||||
}
|
||||
FileKind::Description => video.description_path = Some(path),
|
||||
FileKind::LiveChat => video.has_live_chat = true,
|
||||
FileKind::Video => { if raw.video_path.is_none() { raw.video_path = Some(path); } }
|
||||
FileKind::Thumb => { if raw.thumb_path.is_none() { raw.thumb_path = Some(path); } }
|
||||
FileKind::Description => raw.description_path = Some(path),
|
||||
FileKind::Info => raw.info_path = Some(path),
|
||||
FileKind::LiveChat => raw.has_live_chat = true,
|
||||
FileKind::Other => {}
|
||||
}
|
||||
}
|
||||
let mut videos: Vec<Video> = by_stem.into_values().collect();
|
||||
by_stem.into_values().collect()
|
||||
}
|
||||
|
||||
fn enrich(raws: Vec<RawVideo>) -> Vec<Video> {
|
||||
let mut videos: Vec<Video> = raws.into_iter().map(|raw| {
|
||||
let duration_secs = raw.info_path.as_ref().and_then(|p| {
|
||||
let text = std::fs::read_to_string(p).ok()?;
|
||||
let val: serde_json::Value = serde_json::from_str(&text).ok()?;
|
||||
val.get("duration").and_then(|v| v.as_f64())
|
||||
});
|
||||
let file_size = raw.video_path.as_ref()
|
||||
.and_then(|p| std::fs::metadata(p).ok())
|
||||
.map(|m| m.len());
|
||||
Video {
|
||||
id: raw.id,
|
||||
title: raw.title,
|
||||
stem: raw.stem,
|
||||
video_path: raw.video_path,
|
||||
thumb_path: raw.thumb_path,
|
||||
description_path: raw.description_path,
|
||||
has_live_chat: raw.has_live_chat,
|
||||
duration_secs,
|
||||
file_size,
|
||||
}
|
||||
}).collect();
|
||||
videos.sort_by_key(|v| v.title.to_lowercase());
|
||||
videos
|
||||
}
|
||||
|
||||
pub fn scan_video_files(dir: &Path) -> Vec<Video> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
|
||||
let raws = collect_raw_videos(entries.flatten().filter(|e| e.path().is_file()));
|
||||
enrich(raws)
|
||||
}
|
||||
|
||||
fn scan_channel_dir(dir: &Path) -> (Vec<Video>, Vec<Playlist>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return (Vec::new(), Vec::new());
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return (Vec::new(), Vec::new()) };
|
||||
|
||||
let mut file_entries = Vec::new();
|
||||
let mut playlists = Vec::new();
|
||||
|
|
@ -174,44 +218,8 @@ fn scan_channel_dir(dir: &Path) -> (Vec<Video>, Vec<Playlist>) {
|
|||
}
|
||||
}
|
||||
|
||||
let mut by_stem: BTreeMap<String, Video> = BTreeMap::new();
|
||||
for entry in file_entries {
|
||||
let path = entry.path();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
let Some((stem, kind)) = classify(&file_name) else {
|
||||
continue;
|
||||
};
|
||||
let Some((title, id)) = parse_stem(stem) else {
|
||||
continue;
|
||||
};
|
||||
let video = by_stem.entry(stem.to_string()).or_insert_with(|| Video {
|
||||
id,
|
||||
title,
|
||||
stem: stem.to_string(),
|
||||
video_path: None,
|
||||
thumb_path: None,
|
||||
description_path: None,
|
||||
has_live_chat: false,
|
||||
});
|
||||
match kind {
|
||||
FileKind::Video => {
|
||||
if video.video_path.is_none() {
|
||||
video.video_path = Some(path);
|
||||
}
|
||||
}
|
||||
FileKind::Thumb => {
|
||||
if video.thumb_path.is_none() {
|
||||
video.thumb_path = Some(path);
|
||||
}
|
||||
}
|
||||
FileKind::Description => video.description_path = Some(path),
|
||||
FileKind::LiveChat => video.has_live_chat = true,
|
||||
FileKind::Other => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut videos: Vec<Video> = by_stem.into_values().collect();
|
||||
videos.sort_by_key(|v| v.title.to_lowercase());
|
||||
let raws = collect_raw_videos(file_entries.into_iter());
|
||||
let videos = enrich(raws);
|
||||
playlists.sort_by_key(|p| p.name.to_lowercase());
|
||||
(videos, playlists)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue