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:
parent
ce503e50e2
commit
acf188738a
7 changed files with 5181 additions and 5 deletions
146
src/downloader.rs
Normal file
146
src/downloader.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
//! Running `yt-dlp` in the background and surfacing its progress to the UI.
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc::{channel, Receiver};
|
||||
use std::thread;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum JobState {
|
||||
Running,
|
||||
Done,
|
||||
Failed,
|
||||
}
|
||||
|
||||
enum Msg {
|
||||
Line(String),
|
||||
Progress(f32),
|
||||
Finished(bool),
|
||||
}
|
||||
|
||||
pub struct Job {
|
||||
pub url: String,
|
||||
pub dir_name: String,
|
||||
pub state: JobState,
|
||||
pub progress: f32,
|
||||
pub log: Vec<String>,
|
||||
rx: Receiver<Msg>,
|
||||
}
|
||||
|
||||
impl Job {
|
||||
fn drain(&mut self) {
|
||||
while let Ok(msg) = self.rx.try_recv() {
|
||||
match msg {
|
||||
Msg::Line(line) => {
|
||||
self.log.push(line);
|
||||
if self.log.len() > 800 {
|
||||
let cut = self.log.len() - 800;
|
||||
self.log.drain(0..cut);
|
||||
}
|
||||
}
|
||||
Msg::Progress(p) => self.progress = p,
|
||||
Msg::Finished(ok) => {
|
||||
self.state = if ok { JobState::Done } else { JobState::Failed };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Downloader {
|
||||
pub jobs: Vec<Job>,
|
||||
channels_root: PathBuf,
|
||||
}
|
||||
|
||||
impl Downloader {
|
||||
pub fn new(channels_root: PathBuf) -> Self {
|
||||
Self {
|
||||
jobs: Vec::new(),
|
||||
channels_root,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&mut self, url: String, dir_name: String) {
|
||||
let (tx, rx) = channel();
|
||||
let target_dir = self.channels_root.join(&dir_name);
|
||||
let _ = std::fs::create_dir_all(&target_dir);
|
||||
|
||||
let url_for_thread = url.clone();
|
||||
thread::spawn(move || {
|
||||
let out_template = format!("{}/%(title)s [%(id)s].%(ext)s", target_dir.display());
|
||||
let spawn_result = Command::new("yt-dlp")
|
||||
.arg("--newline")
|
||||
.arg("--no-color")
|
||||
.arg("--no-progress-bar")
|
||||
.arg("--write-description")
|
||||
.arg("--write-thumbnail")
|
||||
.arg("--ignore-errors")
|
||||
.arg("-o")
|
||||
.arg(&out_template)
|
||||
.arg(&url_for_thread)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
|
||||
let mut child = match spawn_result {
|
||||
Ok(child) => child,
|
||||
Err(err) => {
|
||||
let _ = tx.send(Msg::Line(format!("could not launch yt-dlp: {err}")));
|
||||
let _ = tx.send(Msg::Finished(false));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Forward stderr on its own thread so a full pipe can't deadlock stdout.
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let tx = tx.clone();
|
||||
thread::spawn(move || {
|
||||
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
|
||||
let _ = tx.send(Msg::Line(format!("[stderr] {line}")));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
|
||||
if let Some(p) = parse_progress(&line) {
|
||||
let _ = tx.send(Msg::Progress(p));
|
||||
}
|
||||
let _ = tx.send(Msg::Line(line));
|
||||
}
|
||||
}
|
||||
|
||||
let ok = child.wait().map(|s| s.success()).unwrap_or(false);
|
||||
let _ = tx.send(Msg::Finished(ok));
|
||||
});
|
||||
|
||||
self.jobs.push(Job {
|
||||
url,
|
||||
dir_name,
|
||||
state: JobState::Running,
|
||||
progress: 0.0,
|
||||
log: Vec::new(),
|
||||
rx,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn poll(&mut self) {
|
||||
for job in &mut self.jobs {
|
||||
job.drain();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn any_running(&self) -> bool {
|
||||
self.jobs.iter().any(|j| j.state == JobState::Running)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the percentage out of a yt-dlp `[download] 12.3% of ...` line.
|
||||
fn parse_progress(line: &str) -> Option<f32> {
|
||||
let rest = line.trim_start().strip_prefix("[download]")?.trim_start();
|
||||
let pct_end = rest.find('%')?;
|
||||
let value: f32 = rest[..pct_end].trim().parse().ok()?;
|
||||
Some((value / 100.0).clamp(0.0, 1.0))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue