Add the Rust/egui app sources

- library.rs: scans channels/<name>/ into channels + videos by filename stem
- downloader.rs: runs yt-dlp in a background thread, streams progress to the UI
- app.rs / main.rs: channel sidebar, searchable thumbnail list, detail/description
  panel, downloads panel; plays videos via mpv (falls back to xdg-open)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
luna 2026-05-11 01:35:22 -07:00
parent ce503e50e2
commit acf188738a
7 changed files with 5181 additions and 5 deletions

145
src/library.rs Normal file
View file

@ -0,0 +1,145 @@
//! Scanning the `channels/` directory tree into channels 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};
const VIDEO_EXTS: &[&str] = &["mkv", "mp4", "webm", "m4v", "mov", "avi"];
const THUMB_EXTS: &[&str] = &["webp", "jpg", "jpeg", "png"];
#[derive(Clone, Debug)]
pub struct Video {
pub id: String,
pub title: String,
/// The shared filename stem, e.g. `Title [VIDEOID]`.
#[allow(dead_code)]
pub stem: String,
pub video_path: Option<PathBuf>,
pub thumb_path: Option<PathBuf>,
pub description_path: Option<PathBuf>,
pub has_live_chat: bool,
}
#[derive(Clone, Debug)]
pub struct Channel {
pub name: String,
pub path: PathBuf,
pub videos: Vec<Video>,
}
pub fn scan_channels(root: &Path) -> Vec<Channel> {
let mut channels = Vec::new();
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;
}
let name = entry.file_name().to_string_lossy().into_owned();
let videos = scan_channel_dir(&path);
channels.push(Channel { name, path, videos });
}
channels.sort_by_key(|c| c.name.to_lowercase());
channels
}
enum FileKind {
Video,
Thumb,
Description,
LiveChat,
Other,
}
/// Returns `(stem, kind)` for a file name, or `None` if it isn't a per-video file
/// (e.g. yt-dlp's `archive.txt`).
fn classify(file_name: &str) -> Option<(&str, FileKind)> {
// Compound suffixes first.
if let Some(stem) = file_name.strip_suffix(".live_chat.json") {
return Some((stem, FileKind::LiveChat));
}
if let Some(stem) = file_name.strip_suffix(".info.json") {
return Some((stem, FileKind::Other));
}
let dot = file_name.rfind('.')?;
let stem = &file_name[..dot];
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
} else if THUMB_EXTS.contains(&ext.as_str()) {
FileKind::Thumb
} else if ext == "description" {
FileKind::Description
} else {
FileKind::Other
};
Some((stem, kind))
}
/// Splits `Title [VIDEOID]` into `(title, id)`. Requires a trailing `[...]` group.
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;
}
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()))
}
fn scan_channel_dir(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() {
let path = entry.path();
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 {
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());
videos
}