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

View file

@ -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)
}