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
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