add themes, settings GUI, URL-aware download routing, and playlist view

This commit is contained in:
Luna 2026-05-11 03:18:16 -07:00
parent bc06520bc8
commit c11d1c3366
7 changed files with 649 additions and 136 deletions

View file

@ -1,4 +1,4 @@
//! Scanning the `channels/` directory tree into channels 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]`.
@ -14,7 +14,6 @@ const THUMB_EXTS: &[&str] = &["webp", "jpg", "jpeg", "png"];
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>,
@ -23,11 +22,25 @@ pub struct Video {
pub has_live_chat: bool,
}
#[derive(Clone, Debug)]
pub struct Playlist {
pub name: String,
pub path: PathBuf,
pub videos: Vec<Video>,
}
#[derive(Clone, Debug)]
pub struct Channel {
pub name: String,
pub path: PathBuf,
pub videos: Vec<Video>,
pub playlists: Vec<Playlist>,
}
impl Channel {
pub fn total_videos(&self) -> usize {
self.videos.len() + self.playlists.iter().map(|p| p.videos.len()).sum::<usize>()
}
}
pub fn scan_channels(root: &Path) -> Vec<Channel> {
@ -41,8 +54,8 @@ pub fn scan_channels(root: &Path) -> Vec<Channel> {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let videos = scan_channel_dir(&path);
channels.push(Channel { name, path, videos });
let (videos, playlists) = scan_channel_dir(&path);
channels.push(Channel { name, path, videos, playlists });
}
channels.sort_by_key(|c| c.name.to_lowercase());
channels
@ -56,10 +69,7 @@ enum FileKind {
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));
}
@ -84,7 +94,6 @@ fn classify(file_name: &str) -> Option<(&str, FileKind)> {
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('[')?;
@ -97,7 +106,7 @@ fn parse_stem(stem: &str) -> Option<(String, String)> {
Some((title.to_string(), id.to_string()))
}
fn scan_channel_dir(dir: &Path) -> Vec<Video> {
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();
@ -143,3 +152,66 @@ fn scan_channel_dir(dir: &Path) -> Vec<Video> {
videos.sort_by_key(|v| v.title.to_lowercase());
videos
}
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 mut file_entries = Vec::new();
let mut playlists = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = entry.file_name().to_string_lossy().into_owned();
let videos = scan_video_files(&path);
if !videos.is_empty() {
playlists.push(Playlist { name, path, videos });
}
} else {
file_entries.push(entry);
}
}
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());
playlists.sort_by_key(|p| p.name.to_lowercase());
(videos, playlists)
}