add themes, settings GUI, URL-aware download routing, and playlist view
This commit is contained in:
parent
bc06520bc8
commit
c11d1c3366
7 changed files with 649 additions and 136 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue