Add bundled yt-dlp, music library, Plex metadata, sort by date; security + perf hardening
Bug fixes - Desktop auto-rescan: snapshot was_running before check_notifications updates prev_job_states, so the post-download rescan actually fires. - web::run shutdown: replace `let _ = tx;` (which dropped the sender immediately and short-circuited the shutdown select!) with std::mem::forget(tx); add an idle fallback so the `-> !` is honored. - /api/preview now resolves the bundled yt-dlp path and extends PATH for deno discovery, matching the rest of the downloader. - Scheduler interval clamped to >=1h on read so a manually-edited config.toml with 0 hours can't trigger every tick. - maintenance::is_within canonicalizes the parent + filename when the target is missing; remove_files treats NotFound as success. Security - Session tokens stored with issued-at Instant and pruned past 30 day TTL on every touch (was: unbounded HashSet). - Login rate-limited per source IP: 5 failures → 60s lockout (429). - Secure cookie flag added when X-Forwarded-Proto: https is present. - 4 MiB body size cap via DefaultBodyLimit. - Security headers middleware: CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy. - JS safeUrl() defangs javascript:/vbscript:/data:text URLs before interpolation into src= attributes. - Bundled yt-dlp install verifies SHA-256 against the release's SHA2-256SUMS; deno hash printed for visual inspection. Install also reports byte counts every 3s so users see live progress. - yt-offline.db chmod 0600 at open time on Unix. New features - Bundled yt-dlp + deno mode: settings toggle (System/Bundled) plus one-click Install/Update from settings. Ensures executable bit on every launch in case the install chmod was missed. - Download queue with configurable max_concurrent (1–10); pending jobs surfaced in both UIs. - Music download mode: --extract-audio into music/<artist>/, separate sidebar entry, /api/music + /music-files/ endpoints, inline <audio>. - Download quality selector: Best / 1080p / 720p / 480p / 360p. - Sort videos by Newest / Oldest (upload_date from info.json with release_date fallback). - Plex metadata: per-episode .nfo (title, season/episode, aired, runtime, plot from .description), <stem>-thumb.jpg symlink, and show-level tvshow.nfo. - yt-dlp retry/throttle defaults: --retries 30 --fragment-retries 30 --retry-sleep linear=1:30:2 --sleep-requests 1 to ride out the "Connection reset by peer" failures. Performance - password_required cached in AtomicBool; refreshed only on password change. Eliminates a SQLite query per static-file fetch. - Job::log → VecDeque for O(1) front-pop instead of O(n) drain. - Library scan parallelized across cores via stdlib parallel_map. - Web UI poll: 600ms while active, 5s when idle, 2s after errors. - Music scan now recursive (Artist/Album/Track.opus). - percent_encode_segment uses write! to avoid per-byte String allocs. Code quality - 27 unit tests covering parse_progress, parse_stem, extract_after, detect_url_kind, srt_to_vtt, count_cookies, percent_encode_segment, bind_mode_of, hash_password, lang_label, is_within, sanitize, year_of, aired_date, xml_escape. - Unified library::find_video + Channel::all_videos() iterator, replacing three duplicated linear searches. - Downloader::browser wired to --cookies-from-browser as a fallback when no cookies.txt is present. - mpv detection now matches the binary basename, not substring. - Settings modal scroll fix; web settings expose all new fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1d72069913
commit
59996735f3
10 changed files with 2300 additions and 156 deletions
396
src/app.rs
396
src/app.rs
|
|
@ -14,7 +14,7 @@ use eframe::egui;
|
|||
|
||||
use crate::config::Config;
|
||||
use crate::database::Database;
|
||||
use crate::downloader::{detect_url_kind, Downloader, JobState, UrlKind};
|
||||
use crate::downloader::{detect_url_kind, DownloadQuality, Downloader, JobState, UrlKind};
|
||||
use crate::library::{self, Video};
|
||||
use crate::theme;
|
||||
|
||||
|
|
@ -35,14 +35,18 @@ enum SortMode {
|
|||
DurationDesc,
|
||||
SizeAsc,
|
||||
SizeDesc,
|
||||
DateDesc,
|
||||
DateAsc,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum SidebarView {
|
||||
Channels,
|
||||
All,
|
||||
Channel(usize),
|
||||
Playlist(usize, usize),
|
||||
ContinueWatching,
|
||||
Music,
|
||||
}
|
||||
|
||||
struct Card {
|
||||
|
|
@ -54,6 +58,7 @@ struct Card {
|
|||
has_live_chat: bool,
|
||||
duration_secs: Option<f64>,
|
||||
file_size: Option<u64>,
|
||||
upload_date: Option<String>,
|
||||
watched: bool,
|
||||
resume_pos: Option<f64>,
|
||||
}
|
||||
|
|
@ -71,13 +76,19 @@ pub struct App {
|
|||
show_settings: bool,
|
||||
dl_url: String,
|
||||
dl_full_scan: bool,
|
||||
dl_quality: DownloadQuality,
|
||||
dl_music_mode: bool,
|
||||
textures: HashMap<PathBuf, Option<egui::TextureHandle>>,
|
||||
thumb_request_tx: Sender<PathBuf>,
|
||||
thumb_result_rx: Receiver<(PathBuf, Option<egui::ColorImage>)>,
|
||||
thumb_pending: HashSet<PathBuf>,
|
||||
desc_cache: HashMap<PathBuf, String>,
|
||||
status: String,
|
||||
music_library: Vec<library::Track>,
|
||||
music_root: PathBuf,
|
||||
settings_dir: String,
|
||||
settings_plex_path: String,
|
||||
plex_status: String,
|
||||
db: Database,
|
||||
card_density: f32,
|
||||
sort_mode: SortMode,
|
||||
|
|
@ -143,9 +154,18 @@ impl App {
|
|||
let watched = db.get_watched().unwrap_or_default();
|
||||
let resume_positions = db.get_positions().unwrap_or_default();
|
||||
|
||||
let music_root = channels_root.with_file_name("music");
|
||||
let music_library = library::scan_music(&music_root);
|
||||
|
||||
let max_concurrent = config.backup.max_concurrent;
|
||||
let use_bundled_ytdlp = config.backup.use_bundled_ytdlp;
|
||||
let browser = config.player.browser.clone();
|
||||
let config_bind = config.web.bind.clone();
|
||||
let password_set = db.get_setting("password_hash").ok().flatten().is_some();
|
||||
let plex_path_str = config.plex.library_path
|
||||
.as_deref()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let (cookies_pick_tx, cookies_pick_rx) = std::sync::mpsc::channel::<PathBuf>();
|
||||
|
||||
|
|
@ -171,18 +191,24 @@ impl App {
|
|||
sidebar_view: SidebarView::All,
|
||||
selected_video: None,
|
||||
search: String::new(),
|
||||
downloader: Downloader::new(channels_root, browser),
|
||||
downloader: Downloader::new(channels_root, browser, max_concurrent, use_bundled_ytdlp),
|
||||
show_downloads: false,
|
||||
show_settings: false,
|
||||
dl_url: String::new(),
|
||||
dl_full_scan: false,
|
||||
dl_full_scan: true,
|
||||
dl_quality: DownloadQuality::Best,
|
||||
dl_music_mode: false,
|
||||
textures: HashMap::new(),
|
||||
thumb_request_tx,
|
||||
thumb_result_rx,
|
||||
thumb_pending: HashSet::new(),
|
||||
desc_cache: HashMap::new(),
|
||||
status,
|
||||
music_library,
|
||||
music_root,
|
||||
settings_dir,
|
||||
settings_plex_path: plex_path_str,
|
||||
plex_status: String::new(),
|
||||
db,
|
||||
card_density: 1.0,
|
||||
sort_mode: SortMode::Title,
|
||||
|
|
@ -213,6 +239,7 @@ impl App {
|
|||
|
||||
fn rescan(&mut self) {
|
||||
self.library = library::scan_channels(&self.channels_root);
|
||||
self.music_library = library::scan_music(&self.music_root);
|
||||
self.sidebar_view = SidebarView::All;
|
||||
self.selected_video = None;
|
||||
self.desc_cache.clear();
|
||||
|
|
@ -273,12 +300,14 @@ impl App {
|
|||
has_live_chat: v.has_live_chat,
|
||||
duration_secs: v.duration_secs,
|
||||
file_size: v.file_size,
|
||||
upload_date: v.upload_date.clone(),
|
||||
watched: self.watched.contains(&v.id),
|
||||
resume_pos,
|
||||
});
|
||||
};
|
||||
|
||||
match &self.sidebar_view {
|
||||
SidebarView::Channels | SidebarView::Music => { return cards; } // rendered separately
|
||||
SidebarView::ContinueWatching => {
|
||||
for ch in &self.library {
|
||||
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
|
||||
|
|
@ -336,23 +365,29 @@ impl App {
|
|||
cards.sort_by_key(|c| c.file_size.unwrap_or(0));
|
||||
cards.reverse();
|
||||
}
|
||||
SortMode::DateDesc => {
|
||||
// Empty/missing dates sort to the end of "newest first".
|
||||
cards.sort_by(|a, b| b.upload_date.as_deref().unwrap_or("").cmp(a.upload_date.as_deref().unwrap_or("")));
|
||||
}
|
||||
SortMode::DateAsc => {
|
||||
// Empty/missing dates sort to the end of "oldest first" too.
|
||||
cards.sort_by(|a, b| {
|
||||
match (a.upload_date.as_deref(), b.upload_date.as_deref()) {
|
||||
(Some(x), Some(y)) => x.cmp(y),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
cards
|
||||
}
|
||||
|
||||
fn find_video_by_id(&self, id: &str) -> Option<(Video, String)> {
|
||||
for channel in &self.library {
|
||||
if let Some(v) = channel.videos.iter().find(|v| v.id == id) {
|
||||
return Some((v.clone(), channel.name.clone()));
|
||||
}
|
||||
for playlist in &channel.playlists {
|
||||
if let Some(v) = playlist.videos.iter().find(|v| v.id == id) {
|
||||
return Some((v.clone(), channel.name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
library::find_video(&self.library, id)
|
||||
.map(|(v, ch)| (v.clone(), ch.name.clone()))
|
||||
}
|
||||
|
||||
fn texture(&mut self, _ctx: &egui::Context, path: &Path) -> Option<egui::TextureHandle> {
|
||||
|
|
@ -381,7 +416,14 @@ impl App {
|
|||
|
||||
fn play_with_tracking(&mut self, path: &Path, video_id: String) {
|
||||
let cmd = self.config.player.command.clone();
|
||||
let use_mpv_ipc = cmd.contains("mpv");
|
||||
// Only enable IPC for genuine mpv invocations — substring matching
|
||||
// would also fire for things like `mympv-wrapper`, `gnomempv`, etc.,
|
||||
// which don't implement the JSON-IPC protocol.
|
||||
let exe = std::path::Path::new(&cmd)
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(cmd.as_str());
|
||||
let use_mpv_ipc = exe == "mpv" || exe == "mpv.exe";
|
||||
|
||||
#[cfg(unix)]
|
||||
let sock_path = format!("/tmp/yt-offline-{video_id}.sock");
|
||||
|
|
@ -501,7 +543,7 @@ impl App {
|
|||
.collect();
|
||||
for url in urls {
|
||||
let kind = detect_url_kind(&url);
|
||||
self.downloader.start(url, &kind, false);
|
||||
self.downloader.start(url, &kind, true, DownloadQuality::Best);
|
||||
count += 1;
|
||||
}
|
||||
self.status = format!("Scheduled check: started {} channel downloads", count);
|
||||
|
|
@ -582,6 +624,9 @@ impl App {
|
|||
self.show_settings = !self.show_settings;
|
||||
if self.show_settings {
|
||||
self.settings_dir = self.channels_root.display().to_string();
|
||||
self.settings_plex_path = self.config.plex.library_path
|
||||
.as_deref().map(|p| p.display().to_string()).unwrap_or_default();
|
||||
self.plex_status.clear();
|
||||
self.settings_bind_mode =
|
||||
crate::web::bind_mode_of(&self.config.web.bind).to_string();
|
||||
self.settings_password_enabled =
|
||||
|
|
@ -616,6 +661,17 @@ impl App {
|
|||
let total: usize = self.library.iter().map(|c| c.total_videos()).sum();
|
||||
let resume_count = self.resume_positions.len();
|
||||
|
||||
if ui
|
||||
.selectable_label(
|
||||
self.sidebar_view == SidebarView::Channels,
|
||||
format!("⊟ Channels ({})", self.library.len()),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.sidebar_view = SidebarView::Channels;
|
||||
self.selected_video = None;
|
||||
}
|
||||
|
||||
if ui
|
||||
.selectable_label(
|
||||
self.sidebar_view == SidebarView::All,
|
||||
|
|
@ -640,6 +696,18 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
let music_count = self.music_library.len();
|
||||
if ui
|
||||
.selectable_label(
|
||||
self.sidebar_view == SidebarView::Music,
|
||||
format!("♫ Music ({music_count})"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.sidebar_view = SidebarView::Music;
|
||||
self.selected_video = None;
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
// Collect any right-click download action outside the loop
|
||||
|
|
@ -718,7 +786,7 @@ impl App {
|
|||
// Process deferred right-click download action
|
||||
if let Some((url, ch_name)) = pending_ch_download {
|
||||
let kind = detect_url_kind(&url);
|
||||
self.downloader.start(url, &kind, self.dl_full_scan);
|
||||
self.downloader.start(url, &kind, !self.dl_full_scan, DownloadQuality::Best);
|
||||
self.status = format!("Checking {} for new videos…", ch_name);
|
||||
}
|
||||
});
|
||||
|
|
@ -759,14 +827,39 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
ui.checkbox(&mut self.dl_full_scan, "Full scan (check every video, fills gaps)");
|
||||
ui.horizontal(|ui| {
|
||||
ui.selectable_value(&mut self.dl_music_mode, false, "🎬 Video");
|
||||
ui.selectable_value(&mut self.dl_music_mode, true, "🎵 Music");
|
||||
});
|
||||
|
||||
if self.dl_music_mode {
|
||||
ui.label(egui::RichText::new("Audio-only — saves to music/ directory").small().weak());
|
||||
} else {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Quality:");
|
||||
egui::ComboBox::from_id_salt("dl_quality")
|
||||
.selected_text(self.dl_quality.label())
|
||||
.show_ui(ui, |ui| {
|
||||
for &q in DownloadQuality::all() {
|
||||
ui.selectable_value(&mut self.dl_quality, q, q.label());
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.checkbox(&mut self.dl_full_scan, "Fast mode (stop at first already-downloaded video)")
|
||||
.on_hover_text("Faster for large channels but may miss new videos if gaps exist in the archive. Leave off to check every video.");
|
||||
}
|
||||
|
||||
let ready = !self.dl_url.trim().is_empty();
|
||||
if ui.add_enabled(ready, egui::Button::new("⬇ Start download")).clicked() {
|
||||
let url = self.dl_url.trim().to_string();
|
||||
let dest = dest_preview.clone();
|
||||
self.downloader.start(url, &kind, self.dl_full_scan);
|
||||
self.status = format!("Downloading: {dest}");
|
||||
if self.dl_music_mode {
|
||||
self.downloader.start_music(url);
|
||||
self.status = "Downloading music…".to_string();
|
||||
} else {
|
||||
self.downloader.start(url, &kind, !self.dl_full_scan, self.dl_quality);
|
||||
self.status = format!("Downloading: {dest}");
|
||||
}
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
|
@ -780,9 +873,24 @@ impl App {
|
|||
self.prev_job_states.clear();
|
||||
}
|
||||
});
|
||||
if self.downloader.jobs.is_empty() {
|
||||
if self.downloader.jobs.is_empty() && self.downloader.pending_count() == 0 {
|
||||
ui.label(egui::RichText::new("Nothing queued yet.").weak());
|
||||
}
|
||||
let pending_count = self.downloader.pending_count();
|
||||
if pending_count > 0 {
|
||||
let max = self.downloader.max_concurrent;
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"⏳ {pending_count} queued (max {max} concurrent)"
|
||||
))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
let snapshots = self.downloader.pending_snapshots();
|
||||
for (label, _url) in &snapshots {
|
||||
ui.label(egui::RichText::new(format!(" · {label}")).small().weak());
|
||||
}
|
||||
}
|
||||
let mut remove_job: Option<usize> = None;
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
let n = self.downloader.jobs.len();
|
||||
|
|
@ -811,7 +919,7 @@ impl App {
|
|||
if job.state == JobState::Running {
|
||||
ui.add(egui::ProgressBar::new(job.progress).show_percentage());
|
||||
}
|
||||
let last = job.log.last().map(String::as_str).unwrap_or("");
|
||||
let last = job.log.back().map(String::as_str).unwrap_or("");
|
||||
if !last.is_empty() {
|
||||
ui.label(egui::RichText::new(last).small().monospace());
|
||||
}
|
||||
|
|
@ -1046,6 +1154,36 @@ impl App {
|
|||
);
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Max concurrent downloads:");
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut self.config.backup.max_concurrent)
|
||||
.range(1..=10),
|
||||
)
|
||||
.on_hover_text("Maximum simultaneous yt-dlp processes. Extra downloads queue automatically.");
|
||||
ui.end_row();
|
||||
|
||||
ui.label("yt-dlp binary:");
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut self.config.backup.use_bundled_ytdlp, false, "System")
|
||||
.on_hover_text("Use whatever yt-dlp is on PATH.");
|
||||
ui.radio_value(&mut self.config.backup.use_bundled_ytdlp, true, "Bundled")
|
||||
.on_hover_text("Use the yt-dlp + deno installed under ~/.local/share/yt-offline/bin/.");
|
||||
let installed = crate::ytdlp_bin::bundled_installed();
|
||||
let btn_label = if installed { "Update" } else { "Install" };
|
||||
if ui.button(btn_label)
|
||||
.on_hover_text("Download (or update) the bundled yt-dlp + deno from GitHub. Streams output as a job.")
|
||||
.clicked()
|
||||
{
|
||||
self.downloader.start_ytdlp_update();
|
||||
}
|
||||
if installed {
|
||||
ui.label(egui::RichText::new("✓ installed").weak().small());
|
||||
} else {
|
||||
ui.label(egui::RichText::new("not installed").weak().small());
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Web UI port:");
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut self.config.web.port)
|
||||
|
|
@ -1171,6 +1309,46 @@ impl App {
|
|||
ui.end_row();
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.heading("Plex");
|
||||
ui.add_space(4.0);
|
||||
ui.label("Plex library path:");
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.settings_plex_path)
|
||||
.hint_text("/media/plex/YouTube")
|
||||
.desired_width(300.0),
|
||||
);
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Creates a TV-show symlink tree here. Point a Plex TV library at this folder.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
let can_generate = !self.settings_plex_path.trim().is_empty();
|
||||
if ui.add_enabled(can_generate, egui::Button::new("⟳ Generate Plex library")).clicked() {
|
||||
let plex_path = PathBuf::from(self.settings_plex_path.trim());
|
||||
let result = crate::plex::generate(&self.library, &plex_path);
|
||||
self.plex_status = if result.errors.is_empty() {
|
||||
format!("{} link(s) created/updated", result.links_created)
|
||||
} else {
|
||||
format!(
|
||||
"{} link(s) created, {} error(s): {}",
|
||||
result.links_created,
|
||||
result.errors.len(),
|
||||
result.errors.first().unwrap_or(&String::new())
|
||||
)
|
||||
};
|
||||
}
|
||||
if !self.plex_status.is_empty() {
|
||||
ui.label(egui::RichText::new(&self.plex_status).small());
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.add_space(4.0);
|
||||
|
|
@ -1181,6 +1359,14 @@ impl App {
|
|||
let dir_changed = new_dir != self.config.backup.directory;
|
||||
self.config.backup.directory = new_dir.clone();
|
||||
|
||||
// Plex library path
|
||||
let plex_trimmed = self.settings_plex_path.trim();
|
||||
self.config.plex.library_path = if plex_trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PathBuf::from(plex_trimmed))
|
||||
};
|
||||
|
||||
// Resolve the chosen interface to a concrete bind address.
|
||||
let new_bind = crate::web::resolve_bind_mode(&self.settings_bind_mode);
|
||||
let bind_changed = new_bind != self.config.web.bind;
|
||||
|
|
@ -1212,6 +1398,8 @@ impl App {
|
|||
Ok(_) => self.status = "Settings saved.".to_string(),
|
||||
Err(e) => self.status = format!("Error saving config: {e}"),
|
||||
}
|
||||
self.downloader.max_concurrent = self.config.backup.max_concurrent;
|
||||
self.downloader.use_bundled_ytdlp = self.config.backup.use_bundled_ytdlp;
|
||||
if dir_changed {
|
||||
self.channels_root = new_dir.clone();
|
||||
self.downloader.channels_root = new_dir;
|
||||
|
|
@ -1327,7 +1515,154 @@ impl App {
|
|||
});
|
||||
}
|
||||
|
||||
fn channel_grid(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
|
||||
let density = self.card_density;
|
||||
let thumb_w = (176.0 * density).round();
|
||||
let thumb_h = (99.0 * density).round();
|
||||
let card_w = thumb_w + 4.0; // 2px border each side
|
||||
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
let available_w = ui.available_width();
|
||||
let cols = ((available_w / (card_w + 12.0)) as usize).max(1);
|
||||
let n = self.library.len();
|
||||
|
||||
for row_start in (0..n).step_by(cols) {
|
||||
ui.horizontal(|ui| {
|
||||
for i in row_start..(row_start + cols).min(n) {
|
||||
// Collect data we need without holding a borrow into self.library
|
||||
let (name, total, size_bytes, thumb_path) = {
|
||||
let ch = &self.library[i];
|
||||
let thumb = ch.videos.iter()
|
||||
.chain(ch.playlists.iter().flat_map(|p| p.videos.iter()))
|
||||
.find_map(|v| v.thumb_path.clone());
|
||||
(ch.name.clone(), ch.total_videos(), ch.total_size_cached, thumb)
|
||||
};
|
||||
|
||||
ui.push_id(i, |ui| {
|
||||
let (card_rect, card_resp) = ui.allocate_exact_size(
|
||||
egui::vec2(card_w, thumb_h + 46.0 * density),
|
||||
egui::Sense::click(),
|
||||
);
|
||||
let visuals = ui.visuals();
|
||||
let border_color = if card_resp.hovered() {
|
||||
visuals.selection.bg_fill
|
||||
} else {
|
||||
visuals.widgets.noninteractive.bg_stroke.color
|
||||
};
|
||||
ui.painter().rect_stroke(card_rect, 6.0, egui::Stroke::new(2.0, border_color));
|
||||
|
||||
let thumb_rect = egui::Rect::from_min_size(
|
||||
card_rect.min + egui::vec2(2.0, 2.0),
|
||||
egui::vec2(thumb_w, thumb_h),
|
||||
);
|
||||
let texture = thumb_path.as_ref().and_then(|p| self.texture(ctx, p));
|
||||
match &texture {
|
||||
Some(handle) => {
|
||||
egui::Image::new(handle)
|
||||
.maintain_aspect_ratio(true)
|
||||
.paint_at(ui, thumb_rect);
|
||||
}
|
||||
None => {
|
||||
ui.painter().rect_filled(thumb_rect, 4.0, egui::Color32::from_gray(30));
|
||||
ui.painter().text(
|
||||
thumb_rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"📺",
|
||||
egui::FontId::proportional(28.0 * density),
|
||||
egui::Color32::from_gray(100),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let text_top = card_rect.min + egui::vec2(6.0, thumb_h + 6.0);
|
||||
ui.painter().text(
|
||||
text_top,
|
||||
egui::Align2::LEFT_TOP,
|
||||
&name,
|
||||
egui::FontId::proportional(13.0 * density),
|
||||
visuals.text_color(),
|
||||
);
|
||||
let sub = format!(
|
||||
"{} video{}{}",
|
||||
total,
|
||||
if total == 1 { "" } else { "s" },
|
||||
if size_bytes > 0 { format!(" · {}", format_size(size_bytes)) } else { String::new() }
|
||||
);
|
||||
ui.painter().text(
|
||||
text_top + egui::vec2(0.0, 16.0 * density),
|
||||
egui::Align2::LEFT_TOP,
|
||||
&sub,
|
||||
egui::FontId::proportional(11.0 * density),
|
||||
visuals.weak_text_color(),
|
||||
);
|
||||
|
||||
if card_resp.clicked() {
|
||||
self.sidebar_view = SidebarView::Channel(i);
|
||||
self.selected_video = None;
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn music_view(&mut self, ui: &mut egui::Ui) {
|
||||
ui.heading("Music");
|
||||
if self.music_library.is_empty() {
|
||||
ui.label(egui::RichText::new(
|
||||
"No tracks yet. Download audio with Music mode in the Downloads panel."
|
||||
).weak());
|
||||
return;
|
||||
}
|
||||
let mut current_artist = String::new();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
for track in &self.music_library {
|
||||
if track.artist != current_artist {
|
||||
current_artist = track.artist.clone();
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(&track.artist).strong());
|
||||
ui.separator();
|
||||
}
|
||||
ui.push_id(&track.id, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let dur = track.duration_secs
|
||||
.map(|s| {
|
||||
let m = s as u64 / 60;
|
||||
let sec = s as u64 % 60;
|
||||
format!("{m}:{sec:02}")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if ui.selectable_label(false, &track.title).clicked() {
|
||||
if let Err(e) = Command::new(&self.config.player.command)
|
||||
.arg(&track.path)
|
||||
.spawn()
|
||||
{
|
||||
self.status = format!("Could not open player: {e}");
|
||||
}
|
||||
}
|
||||
if !dur.is_empty() {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new(dur).small().weak());
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn video_list(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
|
||||
if self.sidebar_view == SidebarView::Channels {
|
||||
self.channel_grid(ctx, ui);
|
||||
return;
|
||||
}
|
||||
if self.sidebar_view == SidebarView::Music {
|
||||
self.music_view(ui);
|
||||
return;
|
||||
}
|
||||
let cards = self.cards_take();
|
||||
let show_channel = !matches!(self.sidebar_view, SidebarView::Channel(_) | SidebarView::Playlist(_, _));
|
||||
|
||||
|
|
@ -1394,6 +1729,8 @@ impl App {
|
|||
}
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.selectable_value(&mut self.sort_mode, SortMode::DateDesc, "Newest");
|
||||
ui.selectable_value(&mut self.sort_mode, SortMode::DateAsc, "Oldest");
|
||||
ui.selectable_value(&mut self.sort_mode, SortMode::SizeDesc, "Largest");
|
||||
ui.selectable_value(&mut self.sort_mode, SortMode::SizeAsc, "Smallest");
|
||||
ui.selectable_value(&mut self.sort_mode, SortMode::DurationDesc, "Longest");
|
||||
|
|
@ -1605,12 +1942,14 @@ impl App {
|
|||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.downloader.poll();
|
||||
// Snapshot the previous-frame running state BEFORE check_notifications
|
||||
// overwrites prev_job_states with the current frame's. Otherwise
|
||||
// was_running tracks the wrong frame and the auto-rescan never fires.
|
||||
let was_running_prev = self.prev_job_states.values().any(|&s| s == JobState::Running);
|
||||
self.check_notifications();
|
||||
|
||||
let any_running = self.downloader.any_running();
|
||||
// Auto-rescan when all downloads finish
|
||||
let was_running = self.prev_job_states.values().any(|&s| s == JobState::Running);
|
||||
if was_running && !any_running {
|
||||
if was_running_prev && !any_running {
|
||||
self.rescan();
|
||||
}
|
||||
|
||||
|
|
@ -1620,9 +1959,10 @@ impl eframe::App for App {
|
|||
|
||||
// Scheduled channel checks
|
||||
if self.config.scheduler.enabled && !any_running {
|
||||
let interval = std::time::Duration::from_secs(
|
||||
self.config.scheduler.interval_hours as u64 * 3600,
|
||||
);
|
||||
// Defensive clamp: a manually-edited config.toml with 0 hours
|
||||
// would otherwise re-fire every frame.
|
||||
let hours = self.config.scheduler.interval_hours.max(1);
|
||||
let interval = std::time::Duration::from_secs(hours as u64 * 3600);
|
||||
let due = self.last_scheduled_check
|
||||
.map_or(true, |t| t.elapsed() >= interval);
|
||||
if due {
|
||||
|
|
|
|||
|
|
@ -18,14 +18,27 @@ pub struct Config {
|
|||
pub scheduler: SchedulerSection,
|
||||
#[serde(default)]
|
||||
pub web: WebSection,
|
||||
#[serde(default)]
|
||||
pub plex: PlexSection,
|
||||
}
|
||||
|
||||
/// `[backup]` table — where to store downloaded videos.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct BackupSection {
|
||||
pub directory: PathBuf,
|
||||
/// Maximum simultaneous yt-dlp processes. Extra downloads queue and start
|
||||
/// automatically when a slot opens. Set to 0 for no limit (not recommended).
|
||||
#[serde(default = "default_max_concurrent")]
|
||||
pub max_concurrent: usize,
|
||||
/// If true, use the bundled yt-dlp + deno binaries managed by yt-offline
|
||||
/// (installed under `~/.local/share/yt-offline/bin/`). If false, use the
|
||||
/// `yt-dlp` found on the system PATH.
|
||||
#[serde(default)]
|
||||
pub use_bundled_ytdlp: bool,
|
||||
}
|
||||
|
||||
fn default_max_concurrent() -> usize { 3 }
|
||||
|
||||
/// `[player]` table — external player and browser cookie source.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PlayerSection {
|
||||
|
|
@ -71,6 +84,14 @@ impl Default for SchedulerSection {
|
|||
}
|
||||
}
|
||||
|
||||
/// `[plex]` table — Plex-compatible TV-show symlink library.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct PlexSection {
|
||||
/// Directory where the Plex symlink tree is written.
|
||||
/// Leave unset to disable Plex library generation.
|
||||
pub library_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// `[web]` table — built-in HTTP server settings.
|
||||
///
|
||||
/// `source_url` is **required for AGPL §13 compliance**: set it to a URL
|
||||
|
|
@ -125,11 +146,16 @@ impl Config {
|
|||
/// Construct a minimal default config pointing `backup.directory` at `dir`.
|
||||
pub fn default_with_dir(dir: PathBuf) -> Self {
|
||||
Self {
|
||||
backup: BackupSection { directory: dir },
|
||||
backup: BackupSection {
|
||||
directory: dir,
|
||||
max_concurrent: default_max_concurrent(),
|
||||
use_bundled_ytdlp: false,
|
||||
},
|
||||
player: PlayerSection::default(),
|
||||
ui: UiSection::default(),
|
||||
scheduler: SchedulerSection::default(),
|
||||
web: WebSection::default(),
|
||||
plex: PlexSection::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,23 @@ pub struct Database {
|
|||
|
||||
impl Database {
|
||||
/// Open or create the database at `path`, running schema migrations.
|
||||
///
|
||||
/// On Unix the file mode is tightened to `0600` so the Argon2 password
|
||||
/// hash and resume positions aren't readable by other local users. A
|
||||
/// best-effort: failure is logged but doesn't abort startup.
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let conn = Connection::open(path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(meta) = std::fs::metadata(path) {
|
||||
let mut perms = meta.permissions();
|
||||
if perms.mode() & 0o777 != 0o600 {
|
||||
perms.set_mode(0o600);
|
||||
let _ = std::fs::set_permissions(path, perms);
|
||||
}
|
||||
}
|
||||
}
|
||||
let db = Database { conn };
|
||||
db.init_schema()?;
|
||||
Ok(db)
|
||||
|
|
|
|||
|
|
@ -23,12 +23,53 @@
|
|||
//! | `--break-on-existing` | Stop when the archive file records the video as already downloaded |
|
||||
//! | `--download-archive archive.txt` | Record downloaded IDs to avoid re-downloading |
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc::{channel, Receiver};
|
||||
use std::thread;
|
||||
|
||||
use crate::ytdlp_bin;
|
||||
|
||||
/// Video quality level passed as a `-f` format selector to yt-dlp.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DownloadQuality {
|
||||
/// No `-f` flag — yt-dlp picks the best available streams (default).
|
||||
#[default]
|
||||
Best,
|
||||
Res1080,
|
||||
Res720,
|
||||
Res480,
|
||||
Res360,
|
||||
}
|
||||
|
||||
impl DownloadQuality {
|
||||
pub fn format_spec(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Best => None,
|
||||
Self::Res1080 => Some("bestvideo[height<=1080]+bestaudio/best[height<=1080]"),
|
||||
Self::Res720 => Some("bestvideo[height<=720]+bestaudio/best[height<=720]"),
|
||||
Self::Res480 => Some("bestvideo[height<=480]+bestaudio/best[height<=480]"),
|
||||
Self::Res360 => Some("bestvideo[height<=360]+bestaudio/best[height<=360]"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Best => "Best",
|
||||
Self::Res1080 => "1080p",
|
||||
Self::Res720 => "720p",
|
||||
Self::Res480 => "480p",
|
||||
Self::Res360 => "360p",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all() -> &'static [DownloadQuality] {
|
||||
&[Self::Best, Self::Res1080, Self::Res720, Self::Res480, Self::Res360]
|
||||
}
|
||||
}
|
||||
|
||||
/// Describes the kind of YouTube URL being downloaded, which determines the
|
||||
/// output path template passed to yt-dlp.
|
||||
pub enum UrlKind {
|
||||
|
|
@ -96,6 +137,9 @@ enum Msg {
|
|||
Finished(bool),
|
||||
}
|
||||
|
||||
/// Maximum lines retained in [`Job::log`] before old lines are evicted.
|
||||
const JOB_LOG_CAP: usize = 800;
|
||||
|
||||
/// A single yt-dlp invocation tracked by the downloader.
|
||||
pub struct Job {
|
||||
pub url: String,
|
||||
|
|
@ -104,8 +148,8 @@ pub struct Job {
|
|||
pub state: JobState,
|
||||
/// Download progress as a fraction in `[0.0, 1.0]`.
|
||||
pub progress: f32,
|
||||
/// Rolling log buffer — capped at 800 lines to avoid unbounded growth.
|
||||
pub log: Vec<String>,
|
||||
/// Rolling log buffer — capped at [`JOB_LOG_CAP`] lines via O(1) front-pop.
|
||||
pub log: VecDeque<String>,
|
||||
rx: Receiver<Msg>,
|
||||
}
|
||||
|
||||
|
|
@ -114,10 +158,9 @@ impl Job {
|
|||
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);
|
||||
self.log.push_back(line);
|
||||
while self.log.len() > JOB_LOG_CAP {
|
||||
self.log.pop_front();
|
||||
}
|
||||
}
|
||||
Msg::Progress(p) => self.progress = p,
|
||||
|
|
@ -129,18 +172,108 @@ impl Job {
|
|||
}
|
||||
}
|
||||
|
||||
/// Manages all active and recently completed yt-dlp download jobs.
|
||||
/// A download waiting to start once a concurrency slot opens.
|
||||
struct PendingJob {
|
||||
cmd: Command,
|
||||
url: String,
|
||||
label: String,
|
||||
}
|
||||
|
||||
/// Manages all active, queued, and recently completed yt-dlp download jobs.
|
||||
pub struct Downloader {
|
||||
pub jobs: Vec<Job>,
|
||||
pending: VecDeque<PendingJob>,
|
||||
pub channels_root: PathBuf,
|
||||
/// Browser name passed to `--cookies-from-browser` (unused) — cookie file
|
||||
/// is currently always `cookies.txt`.
|
||||
/// Browser name passed to `--cookies-from-browser` *when no cookies.txt
|
||||
/// exists in the working directory*. Set to `"none"` to skip the fallback
|
||||
/// entirely. Pasted/imported cookies.txt always takes precedence.
|
||||
pub browser: String,
|
||||
/// Maximum number of simultaneous yt-dlp processes. 0 = unlimited.
|
||||
pub max_concurrent: usize,
|
||||
/// If true, invoke the bundled yt-dlp under [`ytdlp_bin::bundled_dir`]
|
||||
/// instead of the system PATH yt-dlp.
|
||||
pub use_bundled_ytdlp: bool,
|
||||
}
|
||||
|
||||
impl Downloader {
|
||||
pub fn new(channels_root: PathBuf, browser: String) -> Self {
|
||||
Self { jobs: Vec::new(), channels_root, browser }
|
||||
pub fn new(channels_root: PathBuf, browser: String, max_concurrent: usize, use_bundled_ytdlp: bool) -> Self {
|
||||
Self {
|
||||
jobs: Vec::new(),
|
||||
pending: VecDeque::new(),
|
||||
channels_root,
|
||||
browser,
|
||||
max_concurrent,
|
||||
use_bundled_ytdlp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append the cookie-source flags. Prefers `cookies.txt` in the working
|
||||
/// directory (set up via the cookies UI), falling back to
|
||||
/// `--cookies-from-browser <browser>` when no cookies.txt exists and the
|
||||
/// user has chosen a browser (anything other than `"none"`).
|
||||
fn apply_cookie_flags(&self, cmd: &mut Command) {
|
||||
let cookies_txt = std::path::Path::new("cookies.txt");
|
||||
if cookies_txt.exists() {
|
||||
cmd.arg("--cookies").arg("cookies.txt");
|
||||
} else if !self.browser.is_empty() && self.browser != "none" {
|
||||
cmd.arg("--cookies-from-browser").arg(&self.browser);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append the retry and throttling flags applied to every yt-dlp invocation.
|
||||
///
|
||||
/// YouTube occasionally resets connections mid-transfer; with default
|
||||
/// settings yt-dlp gives up after 10 quick retries. We bump the retry
|
||||
/// count and add a linear backoff so transient resets self-heal. The
|
||||
/// sleep flags throttle per-IP request rate slightly so we don't trip
|
||||
/// YouTube's rate limiter when many channels are being checked at once.
|
||||
fn apply_retry_flags(cmd: &mut Command) {
|
||||
cmd.arg("--retries").arg("30")
|
||||
.arg("--fragment-retries").arg("30")
|
||||
.arg("--retry-sleep").arg("linear=1:30:2")
|
||||
.arg("--sleep-requests").arg("1");
|
||||
}
|
||||
|
||||
/// Build a fresh `Command` invoking the currently configured yt-dlp binary.
|
||||
///
|
||||
/// In bundled mode, defensively re-applies the executable bit on every
|
||||
/// binary inside the bundled bin dir. Without this, downloads can fail
|
||||
/// with EACCES if a previous install left the file un-executable (e.g.
|
||||
/// because the chmod step of the install script never ran).
|
||||
fn ytdlp_cmd(&self) -> Command {
|
||||
let path = ytdlp_bin::ytdlp_invocation(self.use_bundled_ytdlp);
|
||||
if self.use_bundled_ytdlp {
|
||||
ytdlp_bin::ensure_bundled_executable();
|
||||
}
|
||||
Command::new(path)
|
||||
}
|
||||
|
||||
/// Number of jobs waiting in the queue (not yet started).
|
||||
pub fn pending_count(&self) -> usize {
|
||||
self.pending.len()
|
||||
}
|
||||
|
||||
/// Labels and URLs of all queued (not yet started) jobs, in queue order.
|
||||
pub fn pending_snapshots(&self) -> Vec<(String, String)> {
|
||||
self.pending.iter().map(|p| (p.label.clone(), p.url.clone())).collect()
|
||||
}
|
||||
|
||||
/// Promote pending jobs into running slots while capacity allows.
|
||||
fn promote_queued(&mut self) {
|
||||
while !self.pending.is_empty() {
|
||||
if self.max_concurrent > 0 {
|
||||
let running = self.jobs.iter().filter(|j| j.state == JobState::Running).count();
|
||||
if running >= self.max_concurrent { break; }
|
||||
}
|
||||
let p = self.pending.pop_front().unwrap();
|
||||
self.spawn_job(p.cmd, p.url, p.label);
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a command into the pending queue (or start immediately if a slot is free).
|
||||
fn enqueue(&mut self, cmd: Command, url: String, label: String) {
|
||||
self.pending.push_back(PendingJob { cmd, url, label });
|
||||
self.promote_queued();
|
||||
}
|
||||
|
||||
/// Spawn a yt-dlp process for `url` and track it as a new [`Job`].
|
||||
|
|
@ -153,7 +286,7 @@ impl Downloader {
|
|||
/// video — fast for routine channel checks. When `full_scan` is true the
|
||||
/// flag is omitted so every video is checked individually against the
|
||||
/// download archive; slower, but correctly fills gaps in the history.
|
||||
pub fn start(&mut self, url: String, kind: &UrlKind, full_scan: bool) {
|
||||
pub fn start(&mut self, url: String, kind: &UrlKind, full_scan: bool, quality: DownloadQuality) {
|
||||
let archive_path = self.channels_root.join("archive.txt");
|
||||
|
||||
let (out_arg, label) = match kind {
|
||||
|
|
@ -175,12 +308,10 @@ impl Downloader {
|
|||
),
|
||||
};
|
||||
|
||||
let mut cmd = Command::new("yt-dlp");
|
||||
cmd.arg("--newline")
|
||||
.arg("--no-color")
|
||||
.arg("--cookies")
|
||||
.arg("cookies.txt")
|
||||
.arg("--write-subs")
|
||||
let mut cmd = self.ytdlp_cmd();
|
||||
cmd.arg("--newline").arg("--no-color");
|
||||
self.apply_cookie_flags(&mut cmd);
|
||||
cmd.arg("--write-subs")
|
||||
.arg("--write-auto-subs")
|
||||
.arg("--write-thumbnail")
|
||||
.arg("--write-description")
|
||||
|
|
@ -200,6 +331,9 @@ impl Downloader {
|
|||
.arg("--extractor-args")
|
||||
.arg("youtube:player_client=web")
|
||||
.arg("--progress");
|
||||
if let Some(fmt) = quality.format_spec() {
|
||||
cmd.arg("-f").arg(fmt);
|
||||
}
|
||||
if !full_scan {
|
||||
cmd.arg("--break-on-existing");
|
||||
}
|
||||
|
|
@ -210,8 +344,9 @@ impl Downloader {
|
|||
.arg("-o")
|
||||
.arg(&out_arg)
|
||||
.arg(&url);
|
||||
Self::apply_retry_flags(&mut cmd);
|
||||
|
||||
self.spawn_job(cmd, url, label);
|
||||
self.enqueue(cmd, url, label);
|
||||
}
|
||||
|
||||
/// Re-fetch missing sidecar assets (thumbnail, info.json, description,
|
||||
|
|
@ -227,13 +362,10 @@ impl Downloader {
|
|||
let url = format!("https://www.youtube.com/watch?v={video_id}");
|
||||
let label = format!("repair {stem}");
|
||||
|
||||
let mut cmd = Command::new("yt-dlp");
|
||||
cmd.arg("--newline")
|
||||
.arg("--no-color")
|
||||
.arg("--skip-download")
|
||||
.arg("--cookies")
|
||||
.arg("cookies.txt")
|
||||
.arg("--write-thumbnail")
|
||||
let mut cmd = self.ytdlp_cmd();
|
||||
cmd.arg("--newline").arg("--no-color").arg("--skip-download");
|
||||
self.apply_cookie_flags(&mut cmd);
|
||||
cmd.arg("--write-thumbnail")
|
||||
.arg("--write-info-json")
|
||||
.arg("--write-description")
|
||||
.arg("--write-subs")
|
||||
|
|
@ -245,8 +377,62 @@ impl Downloader {
|
|||
.arg("-o")
|
||||
.arg(&out_arg)
|
||||
.arg(&url);
|
||||
Self::apply_retry_flags(&mut cmd);
|
||||
|
||||
self.spawn_job(cmd, url, label);
|
||||
self.enqueue(cmd, url, label);
|
||||
}
|
||||
|
||||
/// Path to the music download directory (sibling of `channels_root`).
|
||||
pub fn music_root(&self) -> PathBuf {
|
||||
self.channels_root.with_file_name("music")
|
||||
}
|
||||
|
||||
/// Download `url` as audio-only, storing tracks in `music/<artist>/`.
|
||||
pub fn start_music(&mut self, url: String) {
|
||||
let music_root = self.music_root();
|
||||
let _ = std::fs::create_dir_all(&music_root);
|
||||
let archive_path = self.channels_root.join("archive.txt");
|
||||
let out_arg = format!(
|
||||
"{}/%(artist,channel|Unknown)s/%(title)s [%(id)s].%(ext)s",
|
||||
music_root.display()
|
||||
);
|
||||
let label = "music".to_string();
|
||||
|
||||
let mut cmd = self.ytdlp_cmd();
|
||||
cmd.arg("--newline").arg("--no-color");
|
||||
self.apply_cookie_flags(&mut cmd);
|
||||
cmd.arg("--extract-audio")
|
||||
.arg("--audio-format")
|
||||
.arg("best")
|
||||
.arg("--audio-quality")
|
||||
.arg("0")
|
||||
.arg("--write-thumbnail")
|
||||
.arg("--write-info-json")
|
||||
.arg("--embed-metadata")
|
||||
.arg("--xattrs")
|
||||
.arg("--extractor-args")
|
||||
.arg("youtube:player_client=web")
|
||||
.arg("--impersonate")
|
||||
.arg("Chrome-146:Macos-26")
|
||||
.arg("--progress")
|
||||
.arg("--download-archive")
|
||||
.arg(archive_path.display().to_string())
|
||||
.arg("-o")
|
||||
.arg(&out_arg)
|
||||
.arg(&url);
|
||||
Self::apply_retry_flags(&mut cmd);
|
||||
|
||||
self.enqueue(cmd, url, label);
|
||||
}
|
||||
|
||||
/// Enqueue a job that downloads (or updates) the bundled yt-dlp + deno
|
||||
/// binaries into [`ytdlp_bin::bundled_dir`]. Streams the curl/unzip output
|
||||
/// into a normal [`Job`] entry so the user sees progress in the UI.
|
||||
pub fn start_ytdlp_update(&mut self) {
|
||||
let cmd = ytdlp_bin::install_command();
|
||||
let url = "https://github.com/yt-dlp/yt-dlp/releases/latest".to_string();
|
||||
let label = "update bundled yt-dlp + deno".to_string();
|
||||
self.enqueue(cmd, url, label);
|
||||
}
|
||||
|
||||
/// Spawn `cmd` on a background thread, streaming its output into a new [`Job`].
|
||||
|
|
@ -256,6 +442,19 @@ impl Downloader {
|
|||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
// Prepend the bundled bin dir to PATH so yt-dlp can locate the bundled
|
||||
// `deno` for JavaScript signature deciphering. Harmless when the dir
|
||||
// doesn't exist or bundled mode is disabled.
|
||||
let bundled_dir = ytdlp_bin::bundled_dir();
|
||||
if bundled_dir.exists() {
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
let new_path = match std::env::var_os("PATH") {
|
||||
Some(existing) => format!("{}{}{}", bundled_dir.display(), sep, existing.to_string_lossy()),
|
||||
None => bundled_dir.display().to_string(),
|
||||
};
|
||||
cmd.env("PATH", new_path);
|
||||
}
|
||||
|
||||
thread::spawn(move || {
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(child) => child,
|
||||
|
|
@ -310,16 +509,18 @@ impl Downloader {
|
|||
let _ = tx.send(Msg::Finished(ok));
|
||||
});
|
||||
|
||||
self.jobs.push(Job { url, label, state: JobState::Running, progress: 0.0, log: Vec::new(), rx });
|
||||
self.jobs.push(Job { url, label, state: JobState::Running, progress: 0.0, log: VecDeque::new(), rx });
|
||||
}
|
||||
|
||||
/// Drain pending messages from all job threads into their log buffers.
|
||||
/// Drain pending messages from all job threads and promote queued jobs.
|
||||
///
|
||||
/// Call this regularly from the UI event loop to pick up progress updates.
|
||||
pub fn poll(&mut self) {
|
||||
for job in &mut self.jobs {
|
||||
job.drain();
|
||||
}
|
||||
// Re-check after draining: finished jobs free slots for queued ones.
|
||||
self.promote_queued();
|
||||
}
|
||||
|
||||
pub fn any_running(&self) -> bool {
|
||||
|
|
@ -349,3 +550,54 @@ fn parse_progress(line: &str) -> Option<f32> {
|
|||
let value: f32 = rest[..pct_end].trim().parse().ok()?;
|
||||
Some((value / 100.0).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_progress_typical() {
|
||||
let p = parse_progress("[download] 42.7% of 100MiB at 5MiB/s ETA 00:10").unwrap();
|
||||
assert!((p - 0.427).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_progress_clamps_to_one() {
|
||||
let p = parse_progress("[download] 150% of garbage").unwrap();
|
||||
assert_eq!(p, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_progress_rejects_non_download_lines() {
|
||||
assert!(parse_progress("[info] Writing thumbnail").is_none());
|
||||
assert!(parse_progress("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_after_strips_at_separator() {
|
||||
assert_eq!(extract_after("https://youtube.com/@handle/videos", "/@"), Some("handle"));
|
||||
assert_eq!(extract_after("https://youtube.com/@handle", "/@"), Some("handle"));
|
||||
assert_eq!(extract_after("https://youtube.com/@handle?x=1", "/@"), Some("handle"));
|
||||
assert_eq!(extract_after("nothing-here", "/@"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_url_for_folder_picks_channel_form_for_ids() {
|
||||
let url = check_url_for_folder("UC1234567890123456789012");
|
||||
assert_eq!(url, "https://www.youtube.com/channel/UC1234567890123456789012");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_url_for_folder_picks_handle_form_otherwise() {
|
||||
let url = check_url_for_folder("LinusTechTips");
|
||||
assert_eq!(url, "https://www.youtube.com/@LinusTechTips");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_url_kind_classifies_paths() {
|
||||
assert!(matches!(detect_url_kind("https://www.youtube.com/playlist?list=PL1"), UrlKind::Playlist));
|
||||
assert!(matches!(detect_url_kind("https://www.youtube.com/watch?v=abc"), UrlKind::Video));
|
||||
assert!(matches!(detect_url_kind("https://youtu.be/abc"), UrlKind::Video));
|
||||
assert!(matches!(detect_url_kind("https://www.youtube.com/@handle"), UrlKind::Channel { .. }));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
272
src/library.rs
272
src/library.rs
|
|
@ -23,6 +23,7 @@ use std::collections::BTreeMap;
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
const VIDEO_EXTS: &[&str] = &["mkv", "mp4", "webm", "m4v", "mov", "avi"];
|
||||
const AUDIO_EXTS: &[&str] = &["mp3", "m4a", "opus", "flac", "ogg", "wav", "aac"];
|
||||
const THUMB_EXTS: &[&str] = &["webp", "jpg", "jpeg", "png"];
|
||||
|
||||
/// A single WebVTT subtitle track discovered alongside a video file.
|
||||
|
|
@ -33,6 +34,20 @@ pub struct Subtitle {
|
|||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// An audio track in the music library.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Track {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub path: PathBuf,
|
||||
pub thumb_path: Option<PathBuf>,
|
||||
#[allow(dead_code)]
|
||||
pub info_path: Option<PathBuf>,
|
||||
pub duration_secs: Option<f64>,
|
||||
pub file_size: Option<u64>,
|
||||
}
|
||||
|
||||
/// A fully enriched video entry, ready to serve to the UI.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Video {
|
||||
|
|
@ -55,6 +70,9 @@ pub struct Video {
|
|||
pub has_chapters: bool,
|
||||
/// Size of the video file on disk; `None` if the video file is missing.
|
||||
pub file_size: Option<u64>,
|
||||
/// Upload date as `YYYYMMDD` (yt-dlp's native format from info.json).
|
||||
/// `None` if the info.json sidecar is missing or lacks the field.
|
||||
pub upload_date: Option<String>,
|
||||
}
|
||||
|
||||
/// A sub-directory inside a channel that contains videos (treated as a playlist).
|
||||
|
|
@ -94,22 +112,58 @@ impl Channel {
|
|||
pub fn total_videos(&self) -> usize {
|
||||
self.total_videos_cached
|
||||
}
|
||||
|
||||
/// Iterate over every [`Video`] in this channel, including those nested
|
||||
/// inside playlists. Used widely; previously open-coded at each call site.
|
||||
pub fn all_videos(&self) -> impl Iterator<Item = &Video> {
|
||||
self.videos
|
||||
.iter()
|
||||
.chain(self.playlists.iter().flat_map(|p| p.videos.iter()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a video by ID across a slice of channels. Returns the matching
|
||||
/// [`Video`] alongside the channel it belongs to.
|
||||
pub fn find_video<'a>(channels: &'a [Channel], id: &str) -> Option<(&'a Video, &'a Channel)> {
|
||||
for ch in channels {
|
||||
if let Some(v) = ch.all_videos().find(|v| v.id == id) {
|
||||
return Some((v, ch));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Scan `root` for channel directories and return them sorted alphabetically.
|
||||
///
|
||||
/// Skips hidden directories (names starting with `.`) and directories that
|
||||
/// contain no recognisable video files.
|
||||
///
|
||||
/// Each channel's per-video info.json reads are parallelised across the
|
||||
/// available CPUs because that's where ~all the time goes for large
|
||||
/// libraries (one fs read + one JSON parse per video, multiplied by hundreds
|
||||
/// or thousands).
|
||||
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();
|
||||
if name.starts_with('.') { continue; }
|
||||
let Ok(entries) = std::fs::read_dir(root) else { return Vec::new() };
|
||||
let dirs: Vec<(String, PathBuf)> = entries
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
if !path.is_dir() { return None; }
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if name.starts_with('.') { return None; }
|
||||
Some((name, path))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Process channels in parallel. We size the worker pool to min(channels, CPUs)
|
||||
// so a small library doesn't spin up needless threads.
|
||||
let n_workers = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(4)
|
||||
.min(dirs.len().max(1));
|
||||
let mut channels = parallel_map(dirs, n_workers, |(name, path)| {
|
||||
let (videos, playlists) = scan_channel_dir(&path);
|
||||
if videos.is_empty() && playlists.is_empty() { continue; }
|
||||
if videos.is_empty() && playlists.is_empty() { return None; }
|
||||
let meta = load_channel_meta(&videos);
|
||||
let total_videos_cached =
|
||||
videos.len() + playlists.iter().map(|p| p.videos.len()).sum::<usize>();
|
||||
|
|
@ -118,7 +172,7 @@ pub fn scan_channels(root: &Path) -> Vec<Channel> {
|
|||
.chain(playlists.iter().flat_map(|p| p.videos.iter()))
|
||||
.filter_map(|v| v.file_size)
|
||||
.sum();
|
||||
channels.push(Channel {
|
||||
Some(Channel {
|
||||
name,
|
||||
path,
|
||||
videos,
|
||||
|
|
@ -126,12 +180,67 @@ pub fn scan_channels(root: &Path) -> Vec<Channel> {
|
|||
meta,
|
||||
total_videos_cached,
|
||||
total_size_cached,
|
||||
});
|
||||
}
|
||||
})
|
||||
})
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
channels.sort_by_key(|c| c.name.to_lowercase());
|
||||
channels
|
||||
}
|
||||
|
||||
/// Fan an `items` slice across `n_workers` threads, applying `f` to each item
|
||||
/// and returning results in the original input order.
|
||||
///
|
||||
/// Stdlib-only mini work-stealer: an atomic index hands out the next slot to
|
||||
/// any worker that's free. Used to parallelise channel-directory scans
|
||||
/// without dragging in rayon.
|
||||
fn parallel_map<I, O, F>(items: Vec<I>, n_workers: usize, f: F) -> Vec<O>
|
||||
where
|
||||
I: Send + 'static,
|
||||
O: Send + 'static + Default,
|
||||
F: Fn(I) -> O + Send + Sync + 'static,
|
||||
{
|
||||
let len = items.len();
|
||||
if len == 0 { return Vec::new(); }
|
||||
if n_workers <= 1 {
|
||||
return items.into_iter().map(f).collect();
|
||||
}
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let items: Vec<Mutex<Option<I>>> = items.into_iter().map(|v| Mutex::new(Some(v))).collect();
|
||||
let items = Arc::new(items);
|
||||
let results: Vec<Mutex<O>> = (0..len).map(|_| Mutex::new(O::default())).collect();
|
||||
let results = Arc::new(results);
|
||||
let next = Arc::new(AtomicUsize::new(0));
|
||||
let f = Arc::new(f);
|
||||
|
||||
let mut handles = Vec::with_capacity(n_workers);
|
||||
for _ in 0..n_workers {
|
||||
let items = items.clone();
|
||||
let results = results.clone();
|
||||
let next = next.clone();
|
||||
let f = f.clone();
|
||||
handles.push(std::thread::spawn(move || {
|
||||
loop {
|
||||
let i = next.fetch_add(1, Ordering::Relaxed);
|
||||
if i >= len { break; }
|
||||
let input = items[i].lock().unwrap().take().unwrap();
|
||||
let out = f(input);
|
||||
*results[i].lock().unwrap() = out;
|
||||
}
|
||||
}));
|
||||
}
|
||||
for h in handles { let _ = h.join(); }
|
||||
|
||||
Arc::try_unwrap(results)
|
||||
.unwrap_or_else(|_| unreachable!("workers joined; refs released"))
|
||||
.into_iter()
|
||||
.map(|m| m.into_inner().unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
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| {
|
||||
|
|
@ -261,7 +370,7 @@ fn collect_raw_videos(entries: impl Iterator<Item = std::fs::DirEntry>) -> Vec<R
|
|||
fn enrich(raws: Vec<RawVideo>) -> Vec<Video> {
|
||||
let mut videos: Vec<Video> = raws.into_iter().map(|raw| {
|
||||
// Parse info.json once for both duration and chapter presence.
|
||||
let (duration_secs, has_chapters) = raw.info_path.as_ref()
|
||||
let (duration_secs, has_chapters, upload_date) = raw.info_path.as_ref()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
|
||||
.map(|val| {
|
||||
|
|
@ -270,9 +379,14 @@ fn enrich(raws: Vec<RawVideo>) -> Vec<Video> {
|
|||
.and_then(|c| c.as_array())
|
||||
.map(|a| !a.is_empty())
|
||||
.unwrap_or(false);
|
||||
(dur, chap)
|
||||
// Prefer `upload_date`; fall back to `release_date` for premiere/live content.
|
||||
let date = val.get("upload_date")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| val.get("release_date").and_then(|v| v.as_str()))
|
||||
.map(|s| s.to_string());
|
||||
(dur, chap, date)
|
||||
})
|
||||
.unwrap_or((None, false));
|
||||
.unwrap_or((None, false, None));
|
||||
let file_size = raw.video_path.as_ref()
|
||||
.and_then(|p| std::fs::metadata(p).ok())
|
||||
.map(|m| m.len());
|
||||
|
|
@ -289,6 +403,7 @@ fn enrich(raws: Vec<RawVideo>) -> Vec<Video> {
|
|||
duration_secs,
|
||||
has_chapters,
|
||||
file_size,
|
||||
upload_date,
|
||||
}
|
||||
}).collect();
|
||||
videos.sort_by_key(|v| v.title.to_lowercase());
|
||||
|
|
@ -328,3 +443,132 @@ fn scan_channel_dir(dir: &Path) -> (Vec<Video>, Vec<Playlist>) {
|
|||
playlists.sort_by_key(|p| p.name.to_lowercase());
|
||||
(videos, playlists)
|
||||
}
|
||||
|
||||
// ── Music library ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Scan `root` (the `music/` directory) for audio tracks, recursively.
|
||||
///
|
||||
/// The top-level subdirectory name is used as the default artist (overridden
|
||||
/// by info.json's `artist`/`creator`/`uploader` when present). Deeper levels
|
||||
/// — e.g. `music/Artist/Album/song.opus` — are walked but the top-level name
|
||||
/// remains the fallback artist so albums don't reset the attribution.
|
||||
pub fn scan_music(root: &Path) -> Vec<Track> {
|
||||
let mut tracks = Vec::new();
|
||||
let Ok(entries) = std::fs::read_dir(root) else { return tracks };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if name.starts_with('.') { continue; }
|
||||
if path.is_dir() {
|
||||
scan_music_dir(&path, &name, &mut tracks);
|
||||
} else if let Some(track) = track_from_path(&path, "") {
|
||||
tracks.push(track);
|
||||
}
|
||||
}
|
||||
tracks.sort_by_key(|t| (t.artist.to_lowercase(), t.title.to_lowercase()));
|
||||
tracks
|
||||
}
|
||||
|
||||
fn scan_music_dir(dir: &Path, folder_artist: &str, tracks: &mut Vec<Track>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if name.starts_with('.') { continue; }
|
||||
if path.is_file() {
|
||||
if let Some(track) = track_from_path(&path, folder_artist) {
|
||||
tracks.push(track);
|
||||
}
|
||||
} else if path.is_dir() {
|
||||
// Recurse into albums/subfolders while preserving the top-level
|
||||
// artist label.
|
||||
scan_music_dir(&path, folder_artist, tracks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_stem_extracts_id_and_title() {
|
||||
let (title, id) = parse_stem("My great video [abc123]").unwrap();
|
||||
assert_eq!(title, "My great video");
|
||||
assert_eq!(id, "abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stem_trims_trailing_dash() {
|
||||
let (title, _id) = parse_stem("Some video - [xyz]").unwrap();
|
||||
assert_eq!(title, "Some video");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stem_rejects_missing_brackets() {
|
||||
assert!(parse_stem("no brackets here").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stem_rejects_empty_id() {
|
||||
assert!(parse_stem("foo []").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stem_handles_brackets_in_title() {
|
||||
// The last [..] is the id; earlier ones are part of the title.
|
||||
let (title, id) = parse_stem("[NSFW] Some title [vidid]").unwrap();
|
||||
assert_eq!(id, "vidid");
|
||||
assert!(title.contains("[NSFW]"));
|
||||
}
|
||||
}
|
||||
|
||||
fn track_from_path(path: &Path, folder_artist: &str) -> Option<Track> {
|
||||
let ext = path.extension()?.to_str()?.to_lowercase();
|
||||
if !AUDIO_EXTS.contains(&ext.as_str()) { return None; }
|
||||
|
||||
let stem = path.file_stem()?.to_string_lossy().into_owned();
|
||||
let (title, id) = parse_stem(&stem)?;
|
||||
|
||||
let dir = path.parent()?;
|
||||
let thumb_path = THUMB_EXTS.iter().find_map(|e| {
|
||||
let p = dir.join(format!("{stem}.{e}"));
|
||||
p.exists().then_some(p)
|
||||
});
|
||||
let info_path = {
|
||||
let p = dir.join(format!("{stem}.info.json"));
|
||||
p.exists().then_some(p)
|
||||
};
|
||||
|
||||
let (duration_secs, resolved_artist) = info_path.as_ref()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
|
||||
.map(|val| {
|
||||
let dur = val.get("duration").and_then(|v| v.as_f64());
|
||||
let art = val.get("artist")
|
||||
.or_else(|| val.get("creator"))
|
||||
.or_else(|| val.get("uploader"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| folder_artist.to_string());
|
||||
(dur, art)
|
||||
})
|
||||
.unwrap_or_else(|| (None, folder_artist.to_string()));
|
||||
|
||||
let artist = if resolved_artist.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
resolved_artist
|
||||
};
|
||||
|
||||
Some(Track {
|
||||
id,
|
||||
title,
|
||||
artist,
|
||||
path: path.to_path_buf(),
|
||||
thumb_path,
|
||||
info_path,
|
||||
duration_secs,
|
||||
file_size: std::fs::metadata(path).ok().map(|m| m.len()),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,8 +19,10 @@ mod database;
|
|||
mod downloader;
|
||||
mod library;
|
||||
mod maintenance;
|
||||
mod plex;
|
||||
mod theme;
|
||||
mod web;
|
||||
mod ytdlp_bin;
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
|
|
|||
|
|
@ -191,11 +191,23 @@ pub fn scan(root: &Path, channels: &[Channel]) -> HealthReport {
|
|||
}
|
||||
|
||||
/// True if `target` resolves to a location inside `root`.
|
||||
///
|
||||
/// `target` may not exist (e.g. the caller is about to delete it). In that
|
||||
/// case we canonicalise the parent directory and join the file name back on,
|
||||
/// so a deleted file still gets a correct safety verdict instead of a false
|
||||
/// "outside library" refusal.
|
||||
fn is_within(root: &Path, target: &Path) -> bool {
|
||||
match (root.canonicalize(), target.canonicalize()) {
|
||||
(Ok(r), Ok(t)) => t.starts_with(r),
|
||||
_ => false,
|
||||
}
|
||||
let Ok(canon_root) = root.canonicalize() else { return false };
|
||||
let canon_target = match target.canonicalize() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
let Some(parent) = target.parent() else { return false };
|
||||
let Some(name) = target.file_name() else { return false };
|
||||
let Ok(canon_parent) = parent.canonicalize() else { return false };
|
||||
canon_parent.join(name)
|
||||
}
|
||||
};
|
||||
canon_target.starts_with(canon_root)
|
||||
}
|
||||
|
||||
/// Delete the given files, refusing any path that escapes `root`.
|
||||
|
|
@ -212,12 +224,54 @@ pub fn remove_files(root: &Path, paths: &[PathBuf]) -> (usize, Vec<String>) {
|
|||
}
|
||||
match std::fs::remove_file(p) {
|
||||
Ok(()) => removed += 1,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
// Already gone — treat as success so a duplicate-delete from
|
||||
// a stale UI doesn't look like an error.
|
||||
removed += 1;
|
||||
}
|
||||
Err(e) => errors.push(format!("{}: {e}", p.display())),
|
||||
}
|
||||
}
|
||||
(removed, errors)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn is_within_accepts_inside_path() {
|
||||
let tmp = std::env::temp_dir().join("yt-offline-iw-test-1");
|
||||
let _ = fs::create_dir_all(&tmp);
|
||||
let inside = tmp.join("foo.txt");
|
||||
let _ = fs::write(&inside, "x");
|
||||
assert!(is_within(&tmp, &inside));
|
||||
let _ = fs::remove_file(&inside);
|
||||
let _ = fs::remove_dir(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_within_rejects_outside_path() {
|
||||
let tmp = std::env::temp_dir().join("yt-offline-iw-test-2");
|
||||
let _ = fs::create_dir_all(&tmp);
|
||||
// The target's parent canonicalises to /tmp, which doesn't start with tmp.
|
||||
let outside = std::env::temp_dir().join("not-our-dir-xyz.txt");
|
||||
assert!(!is_within(&tmp, &outside));
|
||||
let _ = fs::remove_dir(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_within_handles_missing_target() {
|
||||
// Target doesn't exist; parent dir does and is inside root.
|
||||
let tmp = std::env::temp_dir().join("yt-offline-iw-test-3");
|
||||
let _ = fs::create_dir_all(&tmp);
|
||||
let ghost = tmp.join("does-not-exist.txt");
|
||||
assert!(is_within(&tmp, &ghost));
|
||||
let _ = fs::remove_dir(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a video's directory and filename stem by ID, for repair targeting.
|
||||
/// Returns `(dir, stem)` of the first matching copy with a known location.
|
||||
pub fn locate(channels: &[Channel], id: &str) -> Option<(PathBuf, String)> {
|
||||
|
|
|
|||
302
src/plex.rs
Normal file
302
src/plex.rs
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
//! Plex-compatible TV-show library generator.
|
||||
//!
|
||||
//! Creates a directory tree of symlinks under a configurable Plex library root.
|
||||
//! Each channel becomes a show; videos are arranged as episodes numbered by
|
||||
//! upload date, grouped into year-based seasons.
|
||||
//!
|
||||
//! Per-episode metadata (title, plot, aired date, runtime, thumbnail) is
|
||||
//! written as Kodi-format `.nfo` sidecars that Plex's "Personal Media (TV
|
||||
//! Shows)" agent (or the XBMC NFO agent) reads. A show-level `tvshow.nfo`
|
||||
//! carries the channel title and uploader.
|
||||
//!
|
||||
//! # Output structure
|
||||
//!
|
||||
//! ```text
|
||||
//! <plex_root>/
|
||||
//! Channel Name/
|
||||
//! .plexmatch ← legacy show-title hint
|
||||
//! tvshow.nfo ← channel-level metadata
|
||||
//! Season 2023/
|
||||
//! Channel Name - S2023E001 - Video Title.mkv → symlink to real file
|
||||
//! Channel Name - S2023E001 - Video Title.nfo ← episode metadata
|
||||
//! Channel Name - S2023E001 - Video Title-thumb.jpg → symlink to thumb
|
||||
//! Channel Name - S2023E001 - Video Title.en.srt
|
||||
//! Season 2024/
|
||||
//! …
|
||||
//! ```
|
||||
//!
|
||||
//! Existing symlinks are left untouched; the function is safe to re-run after
|
||||
//! new downloads. NFO files are rewritten on each run so updated metadata
|
||||
//! (e.g. corrected titles) propagates.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::library::{Channel, Video};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Replace characters that are invalid in filenames on common filesystems.
|
||||
fn sanitize(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
|
||||
c => c,
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Extract the four-digit year from a `YYYYMMDD` upload_date, or 0 if unavailable.
|
||||
fn year_of(date: &str) -> u32 {
|
||||
if date.len() >= 4 { date[..4].parse().unwrap_or(0) } else { 0 }
|
||||
}
|
||||
|
||||
/// Convert `YYYYMMDD` → `YYYY-MM-DD` for NFO `<aired>` tags. Returns empty string
|
||||
/// for malformed input.
|
||||
fn aired_date(date: &str) -> String {
|
||||
if date.len() >= 8 {
|
||||
format!("{}-{}-{}", &date[..4], &date[4..6], &date[6..8])
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal XML escaping for text-node content in NFO files.
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
/// Read the channel-level description and uploader from the first available
|
||||
/// video's info.json for use in `tvshow.nfo`.
|
||||
fn channel_meta_from_info(videos: &[&Video]) -> (String, String) {
|
||||
for v in videos {
|
||||
let Some(p) = v.info_path.as_ref() else { continue };
|
||||
let Ok(text) = std::fs::read_to_string(p) else { continue };
|
||||
let Ok(val) = serde_json::from_str::<serde_json::Value>(&text) else { continue };
|
||||
let plot = val.get("channel_description")
|
||||
.or_else(|| val.get("uploader_description"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let uploader = val.get("uploader")
|
||||
.or_else(|| val.get("channel"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
return (plot, uploader);
|
||||
}
|
||||
(String::new(), String::new())
|
||||
}
|
||||
|
||||
/// Read a video's full description text. Prefers the `.description` sidecar
|
||||
/// (full text including newlines); falls back to the `description` field of
|
||||
/// info.json if the sidecar is missing.
|
||||
fn read_description(v: &Video) -> String {
|
||||
if let Some(p) = v.description_path.as_ref() {
|
||||
if let Ok(s) = std::fs::read_to_string(p) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
v.info_path.as_ref()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
|
||||
.and_then(|val| val.get("description").and_then(|v| v.as_str()).map(String::from))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Create a symlink at `link` pointing to `target`. Skips if the link already
|
||||
/// exists (including broken symlinks). Returns an error only on creation failure.
|
||||
fn make_symlink(target: &Path, link: &Path) -> Result<(), String> {
|
||||
// symlink_metadata succeeds even for broken symlinks, unlike exists()
|
||||
if link.symlink_metadata().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(target, link)
|
||||
.map_err(|e| format!("{}: {e}", link.display()))?;
|
||||
#[cfg(not(unix))]
|
||||
return Err("symlinks are not supported on this platform".to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitize_replaces_invalid_chars() {
|
||||
assert_eq!(sanitize("a/b:c*d?e\"f<g>h|i\\j"), "a-b-c-d-e-f-g-h-i-j");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn year_of_extracts_year() {
|
||||
assert_eq!(year_of("20240315"), 2024);
|
||||
assert_eq!(year_of(""), 0);
|
||||
assert_eq!(year_of("bad"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aired_date_formats_yyyymmdd() {
|
||||
assert_eq!(aired_date("20240315"), "2024-03-15");
|
||||
assert_eq!(aired_date("bad"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_escape_escapes_special_chars() {
|
||||
assert_eq!(xml_escape("a & b < c > d"), "a & b < c > d");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Result of a Plex library generation run.
|
||||
pub struct GenerateResult {
|
||||
pub links_created: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Generate (or refresh) the Plex symlink tree under `plex_root` from `channels`.
|
||||
///
|
||||
/// Safe to call repeatedly: existing symlinks are skipped, missing ones are added.
|
||||
pub fn generate(channels: &[Channel], plex_root: &Path) -> GenerateResult {
|
||||
let mut links_created = 0;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for ch in channels {
|
||||
let all_videos: Vec<&Video> = ch.videos.iter()
|
||||
.chain(ch.playlists.iter().flat_map(|p| p.videos.iter()))
|
||||
.collect();
|
||||
|
||||
if all_videos.is_empty() { continue; }
|
||||
|
||||
let show_name = sanitize(&ch.name);
|
||||
let show_dir = plex_root.join(&show_name);
|
||||
|
||||
if let Err(e) = std::fs::create_dir_all(&show_dir) {
|
||||
errors.push(format!("mkdir {}: {e}", show_dir.display()));
|
||||
continue;
|
||||
}
|
||||
|
||||
// .plexmatch: helps Plex identify the show by title
|
||||
let _ = std::fs::write(
|
||||
show_dir.join(".plexmatch"),
|
||||
format!("title: {}\n", ch.name),
|
||||
);
|
||||
|
||||
// tvshow.nfo: channel-level metadata (title, uploader, plot).
|
||||
let (channel_plot, uploader) = channel_meta_from_info(&all_videos);
|
||||
let tvshow_nfo = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
|
||||
<tvshow>\n \
|
||||
<title>{}</title>\n \
|
||||
<studio>{}</studio>\n \
|
||||
<plot>{}</plot>\n\
|
||||
</tvshow>\n",
|
||||
xml_escape(&ch.name),
|
||||
xml_escape(&uploader),
|
||||
xml_escape(&channel_plot),
|
||||
);
|
||||
let _ = std::fs::write(show_dir.join("tvshow.nfo"), tvshow_nfo);
|
||||
|
||||
// Sort all videos by upload date for deterministic episode numbering
|
||||
let mut dated: Vec<(&Video, String)> = all_videos.iter()
|
||||
.map(|v| (*v, v.upload_date.clone().unwrap_or_default()))
|
||||
.collect();
|
||||
dated.sort_by(|a, b| a.1.cmp(&b.1));
|
||||
|
||||
// Group into year-based seasons
|
||||
let mut by_year: BTreeMap<u32, Vec<&Video>> = BTreeMap::new();
|
||||
for (v, date) in &dated {
|
||||
by_year.entry(year_of(date)).or_default().push(v);
|
||||
}
|
||||
|
||||
for (year, vids) in &by_year {
|
||||
let season_label = if *year > 0 {
|
||||
format!("Season {year}")
|
||||
} else {
|
||||
"Season 01".to_string()
|
||||
};
|
||||
let season_num = if *year > 0 { *year } else { 1 };
|
||||
let season_dir = show_dir.join(&season_label);
|
||||
|
||||
if let Err(e) = std::fs::create_dir_all(&season_dir) {
|
||||
errors.push(format!("mkdir {}: {e}", season_dir.display()));
|
||||
continue;
|
||||
}
|
||||
|
||||
for (ep_idx, v) in vids.iter().enumerate() {
|
||||
let ep = ep_idx as u32 + 1;
|
||||
let title = sanitize(&v.title);
|
||||
let stem = format!("{show_name} - S{season_num:04}E{ep:03} - {title}");
|
||||
|
||||
// Video file symlink
|
||||
if let Some(ref src) = v.video_path {
|
||||
let ext = src.extension().and_then(|e| e.to_str()).unwrap_or("mkv");
|
||||
let link = season_dir.join(format!("{stem}.{ext}"));
|
||||
match make_symlink(src, &link) {
|
||||
Ok(()) => links_created += 1,
|
||||
Err(e) => errors.push(e),
|
||||
}
|
||||
}
|
||||
|
||||
// Thumbnail symlink — Plex looks for `<stem>-thumb.jpg`.
|
||||
if let Some(ref thumb) = v.thumb_path {
|
||||
let ext = thumb.extension().and_then(|e| e.to_str()).unwrap_or("jpg");
|
||||
let link = season_dir.join(format!("{stem}-thumb.{ext}"));
|
||||
match make_symlink(thumb, &link) {
|
||||
Ok(()) => links_created += 1,
|
||||
Err(e) => errors.push(e),
|
||||
}
|
||||
}
|
||||
|
||||
// Episode NFO sidecar — overwritten on every run so metadata
|
||||
// updates (renames, re-fetched info.json) take effect.
|
||||
let aired = v.upload_date.as_deref()
|
||||
.map(aired_date)
|
||||
.unwrap_or_default();
|
||||
let runtime_min = v.duration_secs
|
||||
.map(|s| (s / 60.0).round() as u64)
|
||||
.unwrap_or(0);
|
||||
let plot = read_description(v);
|
||||
let nfo = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
|
||||
<episodedetails>\n \
|
||||
<title>{}</title>\n \
|
||||
<season>{}</season>\n \
|
||||
<episode>{}</episode>\n \
|
||||
<aired>{}</aired>\n \
|
||||
<runtime>{}</runtime>\n \
|
||||
<plot>{}</plot>\n\
|
||||
</episodedetails>\n",
|
||||
xml_escape(&v.title),
|
||||
season_num,
|
||||
ep,
|
||||
xml_escape(&aired),
|
||||
runtime_min,
|
||||
xml_escape(&plot),
|
||||
);
|
||||
let nfo_path = season_dir.join(format!("{stem}.nfo"));
|
||||
if let Err(e) = std::fs::write(&nfo_path, nfo) {
|
||||
errors.push(format!("write {}: {e}", nfo_path.display()));
|
||||
}
|
||||
|
||||
// Subtitle symlinks — Plex picks these up automatically when
|
||||
// the stem matches: "Show - S01E01 - Title.en.srt"
|
||||
for sub in &v.subtitles {
|
||||
let ext = sub.path.extension().and_then(|e| e.to_str()).unwrap_or("srt");
|
||||
let link = season_dir.join(format!("{stem}.{}.{ext}", sub.lang));
|
||||
match make_symlink(&sub.path, &link) {
|
||||
Ok(()) => links_created += 1,
|
||||
Err(e) => errors.push(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GenerateResult { links_created, errors }
|
||||
}
|
||||
843
src/web.rs
843
src/web.rs
File diff suppressed because it is too large
Load diff
226
src/ytdlp_bin.rs
Normal file
226
src/ytdlp_bin.rs
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
//! Management of bundled yt-dlp + deno binaries.
|
||||
//!
|
||||
//! When [`crate::config::BackupSection::use_bundled_ytdlp`] is enabled, all
|
||||
//! yt-dlp invocations use a binary under `~/.local/share/yt-offline/bin/`
|
||||
//! instead of the system PATH. A bundled `deno` lives alongside so yt-dlp can
|
||||
//! evaluate the JavaScript signature-deciphering code YouTube serves with the
|
||||
//! player — without depending on a system-wide JS runtime.
|
||||
//!
|
||||
//! Both binaries are downloaded on demand from the official GitHub releases by
|
||||
//! [`install_command`], which returns a shell pipeline that curls and unpacks
|
||||
//! them. The pipeline is run as a regular yt-dlp [`crate::downloader::Job`] so
|
||||
//! the user sees progress in the same UI.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
/// Directory holding the bundled binaries (`yt-dlp`, `deno`).
|
||||
pub fn bundled_dir() -> PathBuf {
|
||||
let home = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
home.join(".local").join("share").join("yt-offline").join("bin")
|
||||
}
|
||||
|
||||
/// File name of the yt-dlp executable on this platform.
|
||||
fn ytdlp_filename() -> &'static str {
|
||||
if cfg!(windows) { "yt-dlp.exe" } else { "yt-dlp" }
|
||||
}
|
||||
|
||||
/// Full path the bundled yt-dlp binary should live at.
|
||||
pub fn bundled_ytdlp_path() -> PathBuf {
|
||||
bundled_dir().join(ytdlp_filename())
|
||||
}
|
||||
|
||||
/// Returns the yt-dlp invocation target for [`std::process::Command::new`].
|
||||
///
|
||||
/// With `use_bundled = true` returns the absolute path to the bundled binary
|
||||
/// (even if it does not yet exist — yt-dlp simply fails to launch and the
|
||||
/// error surfaces in the job log, prompting the user to click Update).
|
||||
/// Otherwise returns the bare `"yt-dlp"` string so the system PATH is used.
|
||||
pub fn ytdlp_invocation(use_bundled: bool) -> PathBuf {
|
||||
if use_bundled {
|
||||
bundled_ytdlp_path()
|
||||
} else {
|
||||
PathBuf::from("yt-dlp")
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the bundled yt-dlp binary currently exists on disk.
|
||||
pub fn bundled_installed() -> bool {
|
||||
bundled_ytdlp_path().exists()
|
||||
}
|
||||
|
||||
/// Defensively re-apply `+x` to every regular file inside [`bundled_dir`].
|
||||
///
|
||||
/// Called before each bundled-mode invocation to guard against the install
|
||||
/// script's `chmod` step having been skipped (e.g. partial install) or the
|
||||
/// executable bit having been stripped by some other process.
|
||||
///
|
||||
/// No-op on Windows (executability is determined by the `.exe` suffix there).
|
||||
pub fn ensure_bundled_executable() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = bundled_dir();
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if !p.is_file() { continue; }
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
let mut perms = meta.permissions();
|
||||
let mode = perms.mode();
|
||||
let want = mode | 0o111;
|
||||
if mode != want {
|
||||
perms.set_mode(want);
|
||||
let _ = std::fs::set_permissions(&p, perms);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GitHub release asset name for yt-dlp on this platform.
|
||||
fn ytdlp_asset() -> &'static str {
|
||||
if cfg!(target_os = "windows") {
|
||||
"yt-dlp.exe"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"yt-dlp_macos"
|
||||
} else {
|
||||
"yt-dlp_linux"
|
||||
}
|
||||
}
|
||||
|
||||
/// GitHub release asset name for deno on this platform.
|
||||
fn deno_asset() -> &'static str {
|
||||
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("linux", "x86_64") => "deno-x86_64-unknown-linux-gnu.zip",
|
||||
("linux", "aarch64") => "deno-aarch64-unknown-linux-gnu.zip",
|
||||
("macos", "x86_64") => "deno-x86_64-apple-darwin.zip",
|
||||
("macos", "aarch64") => "deno-aarch64-apple-darwin.zip",
|
||||
("windows", _) => "deno-x86_64-pc-windows-msvc.zip",
|
||||
_ => "deno-x86_64-unknown-linux-gnu.zip",
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`Command`] that installs or updates the bundled yt-dlp + deno
|
||||
/// binaries by running a curl/unzip pipeline through `bash -c` (or PowerShell
|
||||
/// on Windows). On success, both binaries are present in [`bundled_dir`] with
|
||||
/// executable bit set.
|
||||
pub fn install_command() -> Command {
|
||||
let dir = bundled_dir();
|
||||
let dir_str = dir.display().to_string();
|
||||
let ytdlp_url = format!(
|
||||
"https://github.com/yt-dlp/yt-dlp/releases/latest/download/{}",
|
||||
ytdlp_asset()
|
||||
);
|
||||
let deno_url = format!(
|
||||
"https://github.com/denoland/deno/releases/latest/download/{}",
|
||||
deno_asset()
|
||||
);
|
||||
let ytdlp_name = ytdlp_filename();
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let script = format!(
|
||||
"$ErrorActionPreference='Stop'; \
|
||||
New-Item -ItemType Directory -Force -Path '{dir}' | Out-Null; \
|
||||
Write-Host '==> downloading yt-dlp'; \
|
||||
Invoke-WebRequest -Uri '{yurl}' -OutFile '{dir}\\{ybin}'; \
|
||||
Write-Host '==> downloading deno'; \
|
||||
Invoke-WebRequest -Uri '{durl}' -OutFile '{dir}\\deno.zip'; \
|
||||
Write-Host '==> extracting deno'; \
|
||||
Expand-Archive -Force '{dir}\\deno.zip' '{dir}'; \
|
||||
Remove-Item '{dir}\\deno.zip'; \
|
||||
Write-Host '==> versions'; & '{dir}\\{ybin}' --version; & '{dir}\\deno.exe' --version",
|
||||
dir = dir_str, yurl = ytdlp_url, durl = deno_url, ybin = ytdlp_name,
|
||||
);
|
||||
let mut cmd = Command::new("powershell");
|
||||
cmd.arg("-NoProfile").arg("-Command").arg(script);
|
||||
cmd
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Download each file in the background and poll the growing file size
|
||||
// every 3 s so the job log shows visible progress during the transfer
|
||||
// instead of going silent for minutes.
|
||||
//
|
||||
// For yt-dlp we additionally fetch its published `SHA2-256SUMS` file
|
||||
// and verify the binary against it — that file is signed-by-presence
|
||||
// in the same GitHub release, so any tampering would have to compromise
|
||||
// both URLs to slip a bad binary through. Deno doesn't publish a
|
||||
// similarly convenient single-file checksum manifest, so we just print
|
||||
// its SHA-256 for visual inspection.
|
||||
let sums_url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/SHA2-256SUMS";
|
||||
let ytdlp_asset_name = ytdlp_asset();
|
||||
let script = format!(
|
||||
r#"set -e
|
||||
command -v curl >/dev/null || {{ echo 'error: curl not installed'; exit 1; }}
|
||||
command -v unzip >/dev/null || {{ echo 'error: unzip not installed'; exit 1; }}
|
||||
command -v sha256sum >/dev/null || {{ echo 'error: sha256sum not installed'; exit 1; }}
|
||||
mkdir -p '{dir}'
|
||||
|
||||
# ── yt-dlp ──────────────────────────────────────────────────────────────────
|
||||
echo '==> downloading yt-dlp'
|
||||
curl -fL --no-progress-meter --connect-timeout 30 --max-time 600 --retry 3 \
|
||||
-o '{dir}/{ybin}' '{yurl}' &
|
||||
DLPID=$!
|
||||
while kill -0 $DLPID 2>/dev/null; do
|
||||
sleep 3
|
||||
SZ=$(wc -c < '{dir}/{ybin}' 2>/dev/null || echo 0)
|
||||
echo " yt-dlp: $SZ bytes received..."
|
||||
done
|
||||
wait $DLPID
|
||||
echo " done: $(wc -c < '{dir}/{ybin}') bytes"
|
||||
|
||||
echo '==> verifying yt-dlp SHA-256'
|
||||
curl -fL --no-progress-meter --connect-timeout 30 --max-time 60 \
|
||||
-o '{dir}/yt-dlp.sums' '{sums}'
|
||||
EXPECTED=$(awk -v n="{yasset}" '$2==n {{ print $1 }}' '{dir}/yt-dlp.sums' | head -n1)
|
||||
ACTUAL=$(sha256sum '{dir}/{ybin}' | awk '{{ print $1 }}')
|
||||
rm '{dir}/yt-dlp.sums'
|
||||
if [ -z "$EXPECTED" ]; then
|
||||
echo " warn: no checksum entry for {yasset} in SHA2-256SUMS"
|
||||
echo " actual SHA-256: $ACTUAL"
|
||||
elif [ "$EXPECTED" != "$ACTUAL" ]; then
|
||||
echo " error: SHA-256 mismatch"
|
||||
echo " expected: $EXPECTED"
|
||||
echo " actual: $ACTUAL"
|
||||
rm -f '{dir}/{ybin}'
|
||||
exit 1
|
||||
else
|
||||
echo " ok: $ACTUAL"
|
||||
fi
|
||||
chmod +x '{dir}/{ybin}'
|
||||
|
||||
# ── deno ────────────────────────────────────────────────────────────────────
|
||||
echo '==> downloading deno'
|
||||
curl -fL --no-progress-meter --connect-timeout 30 --max-time 600 --retry 3 \
|
||||
-o '{dir}/deno.zip' '{durl}' &
|
||||
DLPID=$!
|
||||
while kill -0 $DLPID 2>/dev/null; do
|
||||
sleep 3
|
||||
SZ=$(wc -c < '{dir}/deno.zip' 2>/dev/null || echo 0)
|
||||
echo " deno.zip: $SZ bytes received..."
|
||||
done
|
||||
wait $DLPID
|
||||
echo " done: $(wc -c < '{dir}/deno.zip') bytes"
|
||||
echo " deno.zip SHA-256: $(sha256sum '{dir}/deno.zip' | awk '{{ print $1 }}')"
|
||||
|
||||
echo '==> extracting deno'
|
||||
unzip -o '{dir}/deno.zip' -d '{dir}'
|
||||
chmod +x '{dir}/deno'
|
||||
rm '{dir}/deno.zip'
|
||||
|
||||
echo '==> versions'
|
||||
'{dir}/{ybin}' --version
|
||||
'{dir}/deno' --version
|
||||
echo '==> done'"#,
|
||||
dir = dir_str, yurl = ytdlp_url, durl = deno_url, ybin = ytdlp_name,
|
||||
sums = sums_url, yasset = ytdlp_asset_name,
|
||||
);
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg("-c").arg(script);
|
||||
cmd
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue