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:
Luna 2026-05-23 04:54:26 -07:00
parent 1d72069913
commit 59996735f3
10 changed files with 2300 additions and 156 deletions

View file

@ -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,15 +827,40 @@ 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);
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();
ui.horizontal(|ui| {
@ -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 {

View file

@ -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(),
}
}
}

View file

@ -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)

View file

@ -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 { .. }));
}
}

View file

@ -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()),
})
}

View file

@ -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();

View file

@ -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
View 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// 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 &amp; b &lt; c &gt; 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 }
}

File diff suppressed because it is too large Load diff

226
src/ytdlp_bin.rs Normal file
View 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
}
}