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:
Luna 2026-05-11 03:47:29 -07:00
parent 95a73b0980
commit 5b3b8fc901
7 changed files with 644 additions and 221 deletions

1
Cargo.lock generated
View file

@ -4820,6 +4820,7 @@ dependencies = [
"image", "image",
"rusqlite", "rusqlite",
"serde", "serde",
"serde_json",
"toml", "toml",
"tray-icon", "tray-icon",
] ]

View file

@ -9,6 +9,7 @@ eframe = "0.29"
image = { version = "0.25", default-features = false, features = ["webp", "jpeg", "png"] } image = { version = "0.25", default-features = false, features = ["webp", "jpeg", "png"] }
toml = "0.8" toml = "0.8"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rusqlite = { version = "0.31", features = ["bundled"] } rusqlite = { version = "0.31", features = ["bundled"] }
tray-icon = "0.13" tray-icon = "0.13"

View file

@ -1,14 +1,35 @@
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::sync::mpsc::Receiver;
use eframe::egui; use eframe::egui;
use crate::config::Config; use crate::config::Config;
use crate::database::Database;
use crate::downloader::{detect_url_kind, Downloader, JobState, UrlKind}; use crate::downloader::{detect_url_kind, Downloader, JobState, UrlKind};
use crate::library::{self, Video}; use crate::library::{self, Video};
use crate::theme; 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 { struct Card {
channel_name: String, channel_name: String,
title: String, title: String,
@ -16,6 +37,10 @@ struct Card {
video_path: Option<PathBuf>, video_path: Option<PathBuf>,
thumb_path: Option<PathBuf>, thumb_path: Option<PathBuf>,
has_live_chat: bool, has_live_chat: bool,
duration_secs: Option<f64>,
file_size: Option<u64>,
watched: bool,
resume_pos: Option<f64>,
} }
pub struct App { pub struct App {
@ -36,6 +61,14 @@ pub struct App {
desc_cache: HashMap<PathBuf, String>, desc_cache: HashMap<PathBuf, String>,
status: String, status: String,
settings_dir: 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 { impl App {
@ -63,6 +96,14 @@ impl App {
library.iter().map(|c| c.total_videos()).sum::<usize>() 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 { Self {
config, config,
config_path, config_path,
@ -72,7 +113,7 @@ impl App {
selected_playlist: None, selected_playlist: None,
selected_video: None, selected_video: None,
search: String::new(), search: String::new(),
downloader: Downloader::new(channels_root), downloader: Downloader::new(channels_root, browser),
show_downloads: false, show_downloads: false,
show_settings: false, show_settings: false,
dl_url: String::new(), dl_url: String::new(),
@ -81,6 +122,14 @@ impl App {
desc_cache: HashMap::new(), desc_cache: HashMap::new(),
status, status,
settings_dir, 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, _ => 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 }; let Some(playlist) = channel.playlists.get(pi) else { continue };
for v in &playlist.videos { Box::new(playlist.videos.iter())
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,
});
}
} else { } else {
let all_videos: Vec<&library::Video> = channel Box::new(
.videos channel.videos.iter()
.iter() .chain(channel.playlists.iter().flat_map(|p| p.videos.iter()))
.chain(channel.playlists.iter().flat_map(|p| p.videos.iter())) )
.collect(); };
for v in all_videos {
if !query.is_empty() for v in videos_iter {
&& !v.title.to_lowercase().contains(&query) if !query.is_empty()
&& !v.id.to_lowercase().contains(&query) && !v.title.to_lowercase().contains(&query)
{ && !v.id.to_lowercase().contains(&query)
continue; {
} 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,
});
} }
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 cards
} }
@ -202,24 +261,79 @@ impl App {
text 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(); let cmd = self.config.player.command.clone();
match Command::new(&cmd).arg(path).spawn() { let use_mpv_ipc = cmd.contains("mpv");
Ok(_) => self.status = format!("Playing {}", file_label(path)),
Err(_) => match Command::new("xdg-open").arg(path).spawn() { #[cfg(unix)]
Ok(_) => self.status = format!("Opened {} in default player", file_label(path)), let sock_path = format!("/tmp/yt-offline-{video_id}.sock");
Err(e) => self.status = format!("Couldn't open {}: {e}", file_label(path)),
}, 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) { fn open_in_file_manager(&mut self, path: &Path) {
let target = if path.is_dir() { path } else { path.parent().unwrap_or(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}"); 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) { fn top_bar(&mut self, ctx: &egui::Context) {
egui::TopBottomPanel::top("top_bar").show(ctx, |ui| { egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
ui.add_space(2.0); ui.add_space(2.0);
@ -230,12 +344,19 @@ impl App {
ui.add( ui.add(
egui::TextEdit::singleline(&mut self.search) egui::TextEdit::singleline(&mut self.search)
.hint_text("filter by title or id") .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() { if !self.search.is_empty() && ui.button("").on_hover_text("clear").clicked() {
self.search.clear(); self.search.clear();
} }
ui.separator(); 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() { if ui.button("⟳ Rescan").clicked() {
self.rescan(); self.rescan();
} }
@ -303,8 +424,7 @@ impl App {
(pl.name.clone(), pl.videos.len()) (pl.name.clone(), pl.videos.len())
}; };
let is_pl = self.selected_playlist == Some((i, pi)); let is_pl = self.selected_playlist == Some((i, pi));
let pl_label = let pl_label = format!("{} ({})", pl_name, pl_len);
format!("{} ({})", pl_name, pl_len);
if ui.selectable_label(is_pl, pl_label).clicked() { if ui.selectable_label(is_pl, pl_label).clicked() {
self.selected_playlist = Some((i, pi)); self.selected_playlist = Some((i, pi));
self.selected_video = None; self.selected_video = None;
@ -417,7 +537,7 @@ impl App {
.open(&mut open) .open(&mut open)
.collapsible(false) .collapsible(false)
.resizable(false) .resizable(false)
.default_width(440.0) .default_width(460.0)
.show(ctx, |ui| { .show(ctx, |ui| {
egui::Grid::new("settings_grid") egui::Grid::new("settings_grid")
.num_columns(2) .num_columns(2)
@ -427,7 +547,7 @@ impl App {
ui.label("Backup directory:"); ui.label("Backup directory:");
ui.add( ui.add(
egui::TextEdit::singleline(&mut self.settings_dir) egui::TextEdit::singleline(&mut self.settings_dir)
.desired_width(280.0) .desired_width(300.0)
.hint_text("/path/to/channels"), .hint_text("/path/to/channels"),
); );
ui.end_row(); ui.end_row();
@ -435,11 +555,33 @@ impl App {
ui.label("Player command:"); ui.label("Player command:");
ui.add( ui.add(
egui::TextEdit::singleline(&mut self.config.player.command) egui::TextEdit::singleline(&mut self.config.player.command)
.desired_width(280.0) .desired_width(300.0)
.hint_text("mpv"), .hint_text("mpv"),
); );
ui.end_row(); 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:"); ui.label("Theme:");
egui::ComboBox::from_id_salt("theme_combo") egui::ComboBox::from_id_salt("theme_combo")
.selected_text( .selected_text(
@ -483,7 +625,11 @@ impl App {
self.rescan(); 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; self.show_settings = open;
@ -498,6 +644,10 @@ impl App {
self.selected_video = None; self.selected_video = None;
return; return;
}; };
let is_watched = self.watched.contains(&selected_id);
let resume_pos = self.resume_positions.get(&selected_id).copied();
egui::TopBottomPanel::bottom("detail") egui::TopBottomPanel::bottom("detail")
.resizable(true) .resizable(true)
.default_height(220.0) .default_height(220.0)
@ -506,29 +656,68 @@ impl App {
ui.add_space(4.0); ui.add_space(4.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.heading(&video.title); ui.heading(&video.title);
if video.video_path.is_some() && ui.button("▶ Play").clicked() {
if let Some(p) = video.video_path.clone() { if video.video_path.is_some() {
self.play(&p); 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 let Some(p) = video.video_path.clone() {
if ui.button("📁 Show file").clicked() { if ui.button("📁 Show file").clicked() {
self.open_in_file_manager(&p); 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.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() { if ui.button("✖ close").clicked() {
self.selected_video = None; 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(); ui.separator();
let description = self.description(&video); let description = self.description(&video);
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.auto_shrink([false, false]) .auto_shrink([false, false])
@ -541,6 +730,32 @@ impl App {
fn video_list(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { fn video_list(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
let cards = self.cards(); let cards = self.cards();
let show_channel = self.selected_channel.is_none(); 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.horizontal(|ui| {
ui.label(format!("{} videos", cards.len())); ui.label(format!("{} videos", cards.len()));
if !self.search.trim().is_empty() { if !self.search.trim().is_empty() {
@ -548,8 +763,18 @@ impl App {
egui::RichText::new(format!("(filtered by \"{}\")", self.search.trim())).weak(), 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(); ui.separator();
if cards.is_empty() { if cards.is_empty() {
ui.add_space(20.0); ui.add_space(20.0);
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
@ -564,19 +789,23 @@ impl App {
}); });
return; return;
} }
let density = self.card_density;
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { 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 { for card in &cards {
let selected = self.selected_video.as_deref() == Some(card.id.as_str()); 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 clicked_card = false;
let mut play_card = false; let mut play_card = false;
let mut toggle_watched_card = false;
ui.horizontal(|ui| { ui.horizontal(|ui| {
let (rect, resp) = let (rect, resp) = ui.allocate_exact_size(thumb_size, egui::Sense::click());
ui.allocate_exact_size(thumb_size, egui::Sense::click()); let texture = card.thumb_path.as_ref().and_then(|p| self.texture(ctx, p));
let texture = card
.thumb_path
.as_ref()
.and_then(|p| self.texture(ctx, p));
match &texture { match &texture {
Some(handle) => { Some(handle) => {
egui::Image::new(handle) egui::Image::new(handle)
@ -584,20 +813,17 @@ impl App {
.paint_at(ui, rect); .paint_at(ui, rect);
} }
None => { None => {
ui.painter().rect_filled( ui.painter().rect_filled(rect, 4.0, egui::Color32::from_gray(38));
rect,
4.0,
egui::Color32::from_gray(38),
);
ui.painter().text( ui.painter().text(
rect.center(), rect.center(),
egui::Align2::CENTER_CENTER, egui::Align2::CENTER_CENTER,
"", "",
egui::FontId::proportional(26.0), egui::FontId::proportional(26.0 * density),
egui::Color32::from_gray(110), egui::Color32::from_gray(110),
); );
} }
} }
if selected { if selected {
ui.painter().rect_stroke( ui.painter().rect_stroke(
rect, rect,
@ -605,6 +831,32 @@ impl App {
egui::Stroke::new(2.0, egui::Color32::from_rgb(120, 170, 230)), 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() { if resp.clicked() {
clicked_card = true; clicked_card = true;
} }
@ -613,10 +865,17 @@ impl App {
} }
ui.vertical(|ui| { ui.vertical(|ui| {
let title_color = if card.watched {
egui::Color32::from_gray(140)
} else {
ui.visuals().text_color()
};
if ui if ui
.selectable_label( .selectable_label(
selected, selected,
egui::RichText::new(&card.title).strong(), egui::RichText::new(&card.title)
.strong()
.color(title_color),
) )
.clicked() .clicked()
{ {
@ -624,12 +883,18 @@ impl App {
} }
ui.horizontal(|ui| { ui.horizontal(|ui| {
if show_channel { if show_channel {
ui.label( ui.label(egui::RichText::new(&card.channel_name).small().weak());
egui::RichText::new(&card.channel_name).small().weak(),
);
ui.label(egui::RichText::new("·").weak()); ui.label(egui::RichText::new("·").weak());
} }
ui.label(egui::RichText::new(&card.id).small().monospace().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 { if card.has_live_chat {
ui.label(egui::RichText::new("· 💬").small().weak()); ui.label(egui::RichText::new("· 💬").small().weak());
} }
@ -645,20 +910,36 @@ impl App {
if card.video_path.is_some() && ui.small_button("▶ Play").clicked() { if card.video_path.is_some() && ui.small_button("▶ Play").clicked() {
play_card = true; 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() { if ui.small_button("Details").clicked() {
clicked_card = true; 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 play_card {
if let Some(p) = card.video_path.clone() { 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()); self.selected_video = Some(card.id.clone());
} else if clicked_card { } else if clicked_card {
self.selected_video = Some(card.id.clone()); self.selected_video = Some(card.id.clone());
} }
if toggle_watched_card {
let id = card.id.clone();
self.toggle_watched(&id);
}
ui.separator(); ui.separator();
} }
}); });
@ -668,9 +949,25 @@ impl App {
impl eframe::App for App { impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.downloader.poll(); 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)); 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.decode_budget = 6;
self.top_bar(ctx); 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> { fn decode_thumbnail(ctx: &egui::Context, path: &Path) -> Option<egui::TextureHandle> {
let image = image::open(path).ok()?; let image = image::open(path).ok()?;
let image = image.thumbnail(384, 216); let image = image.thumbnail(384, 216);
@ -700,3 +1052,35 @@ fn file_label(path: &Path) -> String {
.map(|n| n.to_string_lossy().into_owned()) .map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.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()
}
}

View file

@ -15,10 +15,18 @@ pub struct BackupSection {
pub directory: PathBuf, pub directory: PathBuf,
} }
#[derive(Debug, Serialize, Deserialize, Clone, Default)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PlayerSection { pub struct PlayerSection {
#[serde(default = "default_player")] #[serde(default = "default_player")]
pub command: String, 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)] #[derive(Debug, Serialize, Deserialize, Clone)]
@ -33,13 +41,9 @@ impl Default for UiSection {
} }
} }
fn default_player() -> String { fn default_player() -> String { "mpv".to_string() }
"mpv".to_string() fn default_browser() -> String { "firefox".to_string() }
} fn default_theme() -> String { "dark".to_string() }
fn default_theme() -> String {
"dark".to_string()
}
impl Config { impl Config {
pub fn load(path: &Path) -> Result<Self, Box<dyn std::error::Error>> { pub fn load(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {

View file

@ -1,4 +1,5 @@
use rusqlite::{Connection, Result}; use rusqlite::{Connection, Result};
use std::collections::{HashMap, HashSet};
use std::path::Path; use std::path::Path;
pub struct Database { pub struct Database {
@ -13,39 +14,69 @@ impl Database {
Ok(db) 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<()> { fn init_schema(&self) -> Result<()> {
self.conn.execute_batch( self.conn.execute_batch(
"CREATE TABLE IF NOT EXISTS videos ( "CREATE TABLE IF NOT EXISTS watched (
id TEXT PRIMARY KEY, video_id TEXT PRIMARY KEY,
channel TEXT NOT NULL, watched_at DATETIME DEFAULT CURRENT_TIMESTAMP
title TEXT NOT NULL,
downloaded_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS downloads ( CREATE TABLE IF NOT EXISTS positions (
id INTEGER PRIMARY KEY, video_id TEXT PRIMARY KEY,
url TEXT NOT NULL, position_secs REAL NOT NULL,
directory TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
status TEXT NOT NULL,
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME
);", );",
)?; )?;
Ok(()) 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( self.conn.execute(
"INSERT INTO downloads (url, directory, status) VALUES (?1, ?2, ?3)", "INSERT OR REPLACE INTO positions (video_id, position_secs, updated_at)
[url, dir, status], VALUES (?1, ?2, CURRENT_TIMESTAMP)",
rusqlite::params![video_id, position_secs],
)?; )?;
Ok(()) Ok(())
} }
pub fn update_download_status(&self, id: i64, status: &str) -> Result<()> { pub fn clear_position(&self, video_id: &str) -> Result<()> {
self.conn.execute( self.conn.execute("DELETE FROM positions WHERE video_id = ?1", [video_id])?;
"UPDATE downloads SET status = ?1, completed_at = CURRENT_TIMESTAMP WHERE id = ?2",
[status, &id.to_string()],
)?;
Ok(()) 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)
}
} }

View file

@ -13,7 +13,6 @@ pub enum UrlKind {
Unknown, Unknown,
} }
/// Detects the kind of YouTube URL from a string.
pub fn detect_url_kind(url: &str) -> UrlKind { pub fn detect_url_kind(url: &str) -> UrlKind {
if url.contains("playlist?list=") { if url.contains("playlist?list=") {
return UrlKind::Playlist; 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> { fn extract_after<'a>(url: &'a str, marker: &str) -> Option<&'a str> {
let start = url.find(marker)? + marker.len(); let start = url.find(marker)? + marker.len();
let rest = &url[start..]; let rest = &url[start..];
let end = rest let end = rest.find(|c| c == '/' || c == '?' || c == '&' || c == '#').unwrap_or(rest.len());
.find(|c| c == '/' || c == '?' || c == '&' || c == '#')
.unwrap_or(rest.len());
if end == 0 { None } else { Some(&rest[..end]) } if end == 0 { None } else { Some(&rest[..end]) }
} }
@ -87,16 +84,18 @@ impl Job {
pub struct Downloader { pub struct Downloader {
pub jobs: Vec<Job>, pub jobs: Vec<Job>,
pub channels_root: PathBuf, pub channels_root: PathBuf,
pub browser: String,
} }
impl Downloader { impl Downloader {
pub fn new(channels_root: PathBuf) -> Self { pub fn new(channels_root: PathBuf, browser: String) -> Self {
Self { jobs: Vec::new(), channels_root } Self { jobs: Vec::new(), channels_root, browser }
} }
pub fn start(&mut self, url: String, kind: &UrlKind) { pub fn start(&mut self, url: String, kind: &UrlKind) {
let (tx, rx) = channel(); let (tx, rx) = channel();
let archive_path = self.channels_root.join("archive.txt"); let archive_path = self.channels_root.join("archive.txt");
let browser = self.browser.clone();
let (out_arg, label) = match kind { let (out_arg, label) = match kind {
UrlKind::Channel { handle } => { UrlKind::Channel { handle } => {
@ -119,31 +118,33 @@ impl Downloader {
let url_for_thread = url.clone(); let url_for_thread = url.clone();
thread::spawn(move || { thread::spawn(move || {
let spawn_result = Command::new("yt-dlp") let mut cmd = Command::new("yt-dlp");
.arg("--newline") cmd.arg("--newline")
.arg("--no-color") .arg("--no-color")
.arg("--no-progress-bar")
.arg("--write-subs") .arg("--write-subs")
.arg("--write-thumbnail") .arg("--write-thumbnail")
.arg("--write-description") .arg("--write-description")
.arg("--write-info-json")
.arg("-f") .arg("-f")
.arg("mkv") .arg("mkv")
.arg("--embed-metadata") .arg("--embed-metadata")
.arg("--break-on-existing") .arg("--break-on-existing")
.arg("--cookies-from-browser")
.arg("firefox")
.arg("--download-archive") .arg("--download-archive")
.arg(archive_path.display().to_string()) .arg(archive_path.display().to_string())
.arg("--ignore-errors") .arg("--ignore-errors")
.arg("-o") .arg("-o")
.arg(&out_arg) .arg(&out_arg);
.arg(&url_for_thread)
if !browser.is_empty() && browser != "none" {
cmd.arg("--cookies-from-browser").arg(&browser);
}
cmd.arg(&url_for_thread)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped());
.spawn();
let mut child = match spawn_result { let mut child = match cmd.spawn() {
Ok(child) => child, Ok(child) => child,
Err(err) => { Err(err) => {
let _ = tx.send(Msg::Line(format!("could not launch yt-dlp: {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)); let _ = tx.send(Msg::Finished(ok));
}); });
self.jobs.push(Job { self.jobs.push(Job { url, label, state: JobState::Running, progress: 0.0, log: Vec::new(), rx });
url,
label,
state: JobState::Running,
progress: 0.0,
log: Vec::new(),
rx,
});
} }
pub fn poll(&mut self) { pub fn poll(&mut self) {

View file

@ -1,8 +1,4 @@
//! Scanning the `channels/` directory tree into channels, playlists, and videos. //! 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::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -20,21 +16,32 @@ pub struct Video {
pub thumb_path: Option<PathBuf>, pub thumb_path: Option<PathBuf>,
pub description_path: Option<PathBuf>, pub description_path: Option<PathBuf>,
pub has_live_chat: bool, pub has_live_chat: bool,
pub duration_secs: Option<f64>,
pub file_size: Option<u64>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Playlist { pub struct Playlist {
pub name: String, pub name: String,
#[allow(dead_code)]
pub path: PathBuf, pub path: PathBuf,
pub videos: Vec<Video>, 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)] #[derive(Clone, Debug)]
pub struct Channel { pub struct Channel {
pub name: String, pub name: String,
pub path: PathBuf, pub path: PathBuf,
pub videos: Vec<Video>, pub videos: Vec<Video>,
pub playlists: Vec<Playlist>, pub playlists: Vec<Playlist>,
pub meta: Option<ChannelMeta>,
} }
impl Channel { impl Channel {
@ -45,27 +52,44 @@ impl Channel {
pub fn scan_channels(root: &Path) -> Vec<Channel> { pub fn scan_channels(root: &Path) -> Vec<Channel> {
let mut channels = Vec::new(); let mut channels = Vec::new();
let Ok(entries) = std::fs::read_dir(root) else { let Ok(entries) = std::fs::read_dir(root) else { return channels };
return channels;
};
for entry in entries.flatten() { for entry in entries.flatten() {
let path = entry.path(); let path = entry.path();
if !path.is_dir() { if !path.is_dir() { continue; }
continue;
}
let name = entry.file_name().to_string_lossy().into_owned(); let name = entry.file_name().to_string_lossy().into_owned();
let (videos, playlists) = scan_channel_dir(&path); 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.sort_by_key(|c| c.name.to_lowercase());
channels 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 { enum FileKind {
Video, Video,
Thumb, Thumb,
Description, Description,
LiveChat, LiveChat,
Info,
Other, Other,
} }
@ -74,13 +98,11 @@ fn classify(file_name: &str) -> Option<(&str, FileKind)> {
return Some((stem, FileKind::LiveChat)); return Some((stem, FileKind::LiveChat));
} }
if let Some(stem) = file_name.strip_suffix(".info.json") { 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 dot = file_name.rfind('.')?;
let stem = &file_name[..dot]; let stem = &file_name[..dot];
if stem.is_empty() { if stem.is_empty() { return None; }
return None;
}
let ext = file_name[dot + 1..].to_lowercase(); let ext = file_name[dot + 1..].to_lowercase();
let kind = if VIDEO_EXTS.contains(&ext.as_str()) { let kind = if VIDEO_EXTS.contains(&ext.as_str()) {
FileKind::Video FileKind::Video
@ -98,65 +120,87 @@ fn parse_stem(stem: &str) -> Option<(String, String)> {
let close = stem.rfind(']')?; let close = stem.rfind(']')?;
let open = stem[..close].rfind('[')?; let open = stem[..close].rfind('[')?;
let id = stem[open + 1..close].trim(); let id = stem[open + 1..close].trim();
if id.is_empty() { if id.is_empty() { return None; }
return None;
}
let title = stem[..open].trim().trim_end_matches('-').trim(); let title = stem[..open].trim().trim_end_matches('-').trim();
let title = if title.is_empty() { stem } else { title }; let title = if title.is_empty() { stem } else { title };
Some((title.to_string(), id.to_string())) Some((title.to_string(), id.to_string()))
} }
pub fn scan_video_files(dir: &Path) -> Vec<Video> { struct RawVideo {
let mut by_stem: BTreeMap<String, Video> = BTreeMap::new(); id: String,
let Ok(entries) = std::fs::read_dir(dir) else { title: String,
return Vec::new(); stem: String,
}; video_path: Option<PathBuf>,
for entry in entries.flatten() { 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(); let path = entry.path();
if !path.is_file() { if !path.is_file() { continue; }
continue;
}
let file_name = entry.file_name().to_string_lossy().into_owned(); let file_name = entry.file_name().to_string_lossy().into_owned();
let Some((stem, kind)) = classify(&file_name) else { let Some((stem, kind)) = classify(&file_name) else { continue };
continue; let Some((title, id)) = parse_stem(stem) else { continue };
}; let raw = by_stem.entry(stem.to_string()).or_insert_with(|| RawVideo {
let Some((title, id)) = parse_stem(stem) else {
continue;
};
let video = by_stem.entry(stem.to_string()).or_insert_with(|| Video {
id, id,
title, title,
stem: stem.to_string(), stem: stem.to_string(),
video_path: None, video_path: None,
thumb_path: None, thumb_path: None,
description_path: None, description_path: None,
info_path: None,
has_live_chat: false, has_live_chat: false,
}); });
match kind { match kind {
FileKind::Video => { FileKind::Video => { if raw.video_path.is_none() { raw.video_path = Some(path); } }
if video.video_path.is_none() { FileKind::Thumb => { if raw.thumb_path.is_none() { raw.thumb_path = Some(path); } }
video.video_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::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 => {} 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.sort_by_key(|v| v.title.to_lowercase());
videos 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>) { fn scan_channel_dir(dir: &Path) -> (Vec<Video>, Vec<Playlist>) {
let Ok(entries) = std::fs::read_dir(dir) else { let Ok(entries) = std::fs::read_dir(dir) else { return (Vec::new(), Vec::new()) };
return (Vec::new(), Vec::new());
};
let mut file_entries = Vec::new(); let mut file_entries = Vec::new();
let mut playlists = 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(); let raws = collect_raw_videos(file_entries.into_iter());
for entry in file_entries { let videos = enrich(raws);
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());
playlists.sort_by_key(|p| p.name.to_lowercase()); playlists.sort_by_key(|p| p.name.to_lowercase());
(videos, playlists) (videos, playlists)
} }