catacomb/src/app.rs
Luna de122817af Desktop: shuffle + library backup + theme contrast fixes
Three asks the user surfaced after this session's web-UI wave: bring
the desktop UI to parity with what shipped over there, and fix the
theme contrast bugs where button labels disappeared on certain
themes.

Shuffle (desktop)
- 🎲 button next to ⟳ Rescan in the top bar.
- New `App::shuffle_play` mirrors the web logic: pick a random video
  from {unwatched + has video_path}, fall back to {any downloaded
  video} when everything's been watched, status message either way.
- Uses `rand::seq::SliceRandom::choose` — `rand` was already a
  dependency (via hash_password).
- Plays via the existing `play_with_tracking` path so mpv IPC +
  resume position both work.

Library backup (desktop)
- New "💾 Save library backup…" button under a new "Backup" section
  in the Settings panel.
- Opens an rfd save dialog off the UI thread (mirroring the existing
  cookies file-picker pattern), default filename
  `yt-offline-<unix-ts>.db`.
- New `backup_save_tx/rx` mpsc channel; `update()` polls it and
  `std::fs::copy`s `yt-offline.db` into the chosen destination,
  surfacing the byte count in the status line.

Theme contrast
- Root cause: four of the seven themes set `override_text_color`
  globally, which shadows the per-widget `fg_stroke.color`. That
  meant the "active" widget state (e.g. Dracula's light-purple
  button) rendered light-grey text on a light background —
  invisible. Trans was the worst offender: hot-pink override on
  a light-pink inactive button.
- Fix: set `override_text_color = None` on dracula, trans,
  emo_nocturnal, and emo_coffin. Each already had per-widget
  fg_stroke colours tuned for high contrast against the matching
  bg_fill — those just weren't being used.
- Trans theme additionally gets a pure-black `inactive.fg_stroke`
  (over the light pink button) and a darker `noninteractive.fg_stroke`
  (over the pale pink panel) for maximum legibility. Emo Scene Queen
  was already correct (no override) and stays as-is.

55 unit tests still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 03:09:50 -07:00

3280 lines
147 KiB
Rust

//! egui desktop application — the main window, sidebar, video grid, and settings UI.
//!
//! The [`App`] struct holds all UI state and implements [`eframe::App`].
//! Background work (downloads, scheduled rescans) runs in threads and
//! communicates back via `mpsc` channels stored on `App`.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::mpsc::{Receiver, Sender};
use std::time::Instant;
use eframe::egui;
use crate::config::Config;
use crate::database::Database;
use crate::downloader::{DownloadQuality, Downloader, JobState};
use crate::platform::{self, classify_url, Platform, UrlKind};
use crate::library::{self, Video};
use crate::theme;
const BROWSERS: &[(&str, &str)] = &[
("firefox", "Firefox"),
("chromium", "Chromium"),
("chrome", "Chrome"),
("opera", "Opera"),
("brave", "Brave"),
("vivaldi", "Vivaldi"),
("none", "None (no cookies)"),
];
#[derive(Clone, PartialEq)]
enum SortMode {
Title,
DurationAsc,
DurationDesc,
SizeAsc,
SizeDesc,
DateDesc,
DateAsc,
}
#[derive(Clone, PartialEq)]
enum SidebarView {
Channels,
All,
Channel(usize),
Playlist(usize, usize),
ContinueWatching,
/// Activity feed — recent additions across all channels, sorted by mtime.
Recent,
/// Smart folder: videos with `favourite` flag set.
Favourites,
/// Smart folder: videos with `bookmark` flag set.
Bookmarks,
/// Smart folder: videos with `waiting` flag set.
Waiting,
Music,
}
struct Card {
channel_name: String,
title: String,
id: String,
video_path: Option<PathBuf>,
thumb_path: Option<PathBuf>,
has_live_chat: bool,
duration_secs: Option<f64>,
file_size: Option<u64>,
upload_date: Option<String>,
mtime_unix: Option<u64>,
watched: bool,
bookmark: bool,
favourite: bool,
waiting: bool,
resume_pos: Option<f64>,
}
pub struct App {
config: Config,
config_path: PathBuf,
channels_root: PathBuf,
/// Parent of `channels_root`. Owns every platform's sibling directory
/// (`channels/`, `tiktok/`, …). Used as the maintenance scan root so
/// non-YouTube content is included.
library_root: PathBuf,
library: Vec<library::Channel>,
sidebar_view: SidebarView,
selected_video: Option<String>,
search: String,
downloader: Downloader,
show_downloads: bool,
show_settings: bool,
dl_url: String,
dl_full_scan: bool,
dl_quality: DownloadQuality,
dl_music_mode: bool,
/// Record an ongoing live stream from the start instead of joining live.
dl_live: bool,
/// For Twitch channel URLs: target the `/clips` listing instead of the
/// default profile (VODs / past broadcasts). Has no effect on other
/// platforms or on single-video URLs.
dl_twitch_clips: 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,
settings_source_url: String,
plex_status: String,
db: Database,
card_density: f32,
sort_mode: SortMode,
watched: HashSet<String>,
/// Per-video flag sets (bookmark/favourite/waiting/archive). Loaded from
/// SQLite at startup, mirrored into the DB on every toggle.
flags: crate::database::VideoFlagsBundle,
/// Channel-organisation folders loaded from the `folders` table. The
/// sidebar groups channels by `Channel.folder_id`; channels with no
/// folder fall under "Unfiled".
folders: Vec<crate::database::FolderRecord>,
resume_positions: HashMap<String, f64>,
prev_job_states: HashMap<usize, JobState>,
currently_playing: Option<String>,
mpv_rx: Option<Receiver<(String, f64)>>,
// Bulk selection
bulk_mode: bool,
bulk_selected: HashSet<String>,
// Scheduler
last_scheduled_check: Option<Instant>,
// Cards cache — recomputed only when inputs change
cards_cache: Vec<Card>,
cards_cache_key: Option<(String, SortMode, SidebarView, u64)>,
library_generation: u64,
// Web server
web_server_running: bool,
web_server_shutdown: Option<Sender<()>>,
// Settings-window scratch state (not persisted directly)
settings_bind_mode: String,
settings_password_enabled: bool,
settings_password_input: String,
settings_cookies_input: String,
settings_cookies_status: String,
// File picker → chosen cookies.txt path arrives here from a dialog thread.
cookies_pick_tx: Sender<PathBuf>,
cookies_pick_rx: Receiver<PathBuf>,
// "Save library backup" target path picked by an rfd dialog on a
// background thread, polled in update() the same way cookies_pick_rx
// is.
backup_save_tx: Sender<PathBuf>,
backup_save_rx: Receiver<PathBuf>,
// Maintenance (library health) window
show_maintenance: bool,
health_report: Option<crate::maintenance::HealthReport>,
// Statistics window
show_stats: bool,
stats_report: Option<crate::stats::StatsReport>,
// Per-channel download-options dialog state
show_channel_options: bool,
/// `(platform, handle)` identifying the channel being edited.
channel_options_target: Option<(Platform, String)>,
// Folder management dialog state.
show_folder_manager: bool,
folder_create_buffer: String,
// Move-to-folder picker state.
show_move_to_folder: bool,
move_to_folder_target: Option<(Platform, String)>,
/// Editable form fields, mirroring [`DownloadOptions`] with string-typed
/// "text" buffers so the user can type partial values (e.g. an empty
/// limit-rate field) without us forcing zeroes back.
channel_options_form: ChannelOptionsForm,
}
/// Scratch struct for the per-channel options dialog. Distinct from
/// [`crate::download_options::DownloadOptions`] because the user is allowed
/// to leave numeric fields blank (which we render as `None`).
#[derive(Default, Clone)]
struct ChannelOptionsForm {
quality_idx: usize, // 0=default, 1=Best, 2=1080p, 3=720p, 4=480p, 5=360p
audio_only: bool,
fetch_comments: bool,
limit_rate_kb: String,
min_filesize_mb: String,
max_filesize_mb: String,
date_after: String,
match_filter: String,
subtitle_langs: String, // comma-separated
extra_args: String, // one per line
}
impl App {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let config_path = cwd.join("config.toml");
let config = match Config::load(&config_path) {
Ok(c) => c,
Err(e) => {
eprintln!("Warning: failed to load config.toml: {e}. Using defaults.");
Config::default_with_dir(cwd.join("channels"))
}
};
theme::apply(&cc.egui_ctx, &config.ui.theme);
let channels_root = config.backup.directory.clone();
let settings_dir = channels_root.display().to_string();
let _ = std::fs::create_dir_all(&channels_root);
let library_root = channels_root
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| channels_root.clone());
let _ = std::fs::create_dir_all(&library_root);
// Pre-create every platform's folder so scans see them.
for &p in Platform::all() {
let dir = platform::platform_root(&channels_root, p);
let _ = std::fs::create_dir_all(&dir);
}
let mut library = library::scan_channels(&channels_root);
let status = format!(
"{} channels, {} videos",
library.len(),
library.iter().map(|c| c.total_videos()).sum::<usize>()
);
let db_path = channels_root.join("yt-offline.db");
let db = Database::open(&db_path)
.unwrap_or_else(|_| Database::open_in_memory().expect("in-memory db failed"));
// Hydrate per-channel download options + folder assignments from
// SQLite onto the scanned library before publishing it to the UI.
if let Ok(map) = db.get_all_channel_options() {
library::apply_channel_options(&mut library, &map);
}
if let Ok(folder_map) = db.get_all_channel_assignments() {
library::apply_channel_folders(&mut library, &folder_map);
}
let folders = db.list_folders().unwrap_or_default();
let watched = db.get_watched().unwrap_or_default();
let flags = db.get_video_flags().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 source_url_str = config.web.source_url.clone().unwrap_or_default();
let (cookies_pick_tx, cookies_pick_rx) = std::sync::mpsc::channel::<PathBuf>();
let (backup_save_tx, backup_save_rx) = std::sync::mpsc::channel::<PathBuf>();
let (thumb_request_tx, thumb_request_rx) = std::sync::mpsc::channel::<PathBuf>();
let (thumb_result_tx, thumb_result_rx) =
std::sync::mpsc::channel::<(PathBuf, Option<egui::ColorImage>)>();
let ctx = cc.egui_ctx.clone();
std::thread::spawn(move || {
while let Ok(path) = thumb_request_rx.recv() {
let img = decode_thumbnail_image(&path);
if thumb_result_tx.send((path, img)).is_err() {
break;
}
ctx.request_repaint();
}
});
Self {
config,
config_path,
channels_root: channels_root.clone(),
library_root,
library,
sidebar_view: SidebarView::All,
selected_video: None,
search: String::new(),
downloader: Downloader::new(channels_root, browser, max_concurrent, use_bundled_ytdlp),
show_downloads: false,
show_settings: false,
dl_url: String::new(),
dl_full_scan: true,
dl_quality: DownloadQuality::Best,
dl_music_mode: false,
dl_live: false,
dl_twitch_clips: 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,
settings_source_url: source_url_str,
plex_status: String::new(),
db,
card_density: 1.0,
sort_mode: SortMode::Title,
watched,
flags,
folders,
resume_positions,
prev_job_states: HashMap::new(),
currently_playing: None,
mpv_rx: None,
bulk_mode: false,
bulk_selected: HashSet::new(),
last_scheduled_check: None,
cards_cache: Vec::new(),
cards_cache_key: None,
library_generation: 0,
web_server_running: false,
web_server_shutdown: None,
settings_bind_mode: crate::web::bind_mode_of(&config_bind).to_string(),
settings_password_enabled: password_set,
settings_password_input: String::new(),
settings_cookies_input: String::new(),
settings_cookies_status: String::new(),
cookies_pick_tx,
cookies_pick_rx,
backup_save_tx,
backup_save_rx,
show_maintenance: false,
health_report: None,
show_stats: false,
stats_report: None,
show_channel_options: false,
channel_options_target: None,
channel_options_form: ChannelOptionsForm::default(),
show_folder_manager: false,
folder_create_buffer: String::new(),
show_move_to_folder: false,
move_to_folder_target: None,
}
}
/// Convert a [`DownloadOptions`] into the editable form state. Numeric
/// `None`s become empty strings.
fn options_to_form(opts: &crate::download_options::DownloadOptions) -> ChannelOptionsForm {
let quality_idx = match opts.quality {
None => 0,
Some(DownloadQuality::Best) => 1,
Some(DownloadQuality::Res1080) => 2,
Some(DownloadQuality::Res720) => 3,
Some(DownloadQuality::Res480) => 4,
Some(DownloadQuality::Res360) => 5,
};
let num = |v: Option<u32>| v.map(|n| n.to_string()).unwrap_or_default();
ChannelOptionsForm {
quality_idx,
audio_only: opts.audio_only,
fetch_comments: opts.fetch_comments,
limit_rate_kb: num(opts.limit_rate_kb),
min_filesize_mb: num(opts.min_filesize_mb),
max_filesize_mb: num(opts.max_filesize_mb),
date_after: opts.date_after.clone().unwrap_or_default(),
match_filter: opts.match_filter.clone().unwrap_or_default(),
subtitle_langs: opts.subtitle_langs.join(", "),
extra_args: opts.extra_args.join("\n"),
}
}
/// Inverse of [`options_to_form`]. Empty/whitespace string fields map
/// back to `None`.
fn form_to_options(f: &ChannelOptionsForm) -> crate::download_options::DownloadOptions {
let quality = match f.quality_idx {
1 => Some(DownloadQuality::Best),
2 => Some(DownloadQuality::Res1080),
3 => Some(DownloadQuality::Res720),
4 => Some(DownloadQuality::Res480),
5 => Some(DownloadQuality::Res360),
_ => None,
};
let parse_num = |s: &str| s.trim().parse::<u32>().ok().filter(|&n| n > 0);
let trim_opt = |s: &str| {
let t = s.trim();
if t.is_empty() { None } else { Some(t.to_string()) }
};
crate::download_options::DownloadOptions {
quality,
audio_only: f.audio_only,
fetch_comments: f.fetch_comments,
limit_rate_kb: parse_num(&f.limit_rate_kb),
min_filesize_mb: parse_num(&f.min_filesize_mb),
max_filesize_mb: parse_num(&f.max_filesize_mb),
date_after: trim_opt(&f.date_after),
match_filter: trim_opt(&f.match_filter),
subtitle_langs: f.subtitle_langs.split(',')
.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
extra_args: f.extra_args.lines()
.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
}
}
fn rescan(&mut self) {
let mut new_lib = library::scan_channels(&self.channels_root);
if let Ok(map) = self.db.get_all_channel_options() {
library::apply_channel_options(&mut new_lib, &map);
}
if let Ok(folder_map) = self.db.get_all_channel_assignments() {
library::apply_channel_folders(&mut new_lib, &folder_map);
}
self.folders = self.db.list_folders().unwrap_or_default();
self.library = new_lib;
self.music_library = library::scan_music(&self.music_root);
self.sidebar_view = SidebarView::All;
self.selected_video = None;
self.desc_cache.clear();
// Keep already-decoded thumbnail textures across rescans — they're keyed
// by file path and don't change. Drop only entries whose thumbnail is no
// longer in the library so removed videos don't leak GPU textures. This
// avoids the full thumbnail reload flicker on every rescan.
let valid: HashSet<PathBuf> = self
.library
.iter()
.flat_map(|c| c.videos.iter().chain(c.playlists.iter().flat_map(|p| p.videos.iter())))
.filter_map(|v| v.thumb_path.clone())
.collect();
self.textures.retain(|p, _| valid.contains(p));
self.thumb_pending.retain(|p| valid.contains(p));
// Bump the generation so the card cache recomputes against the new library.
self.library_generation += 1;
self.status = format!(
"Rescanned: {} channels, {} videos",
self.library.len(),
self.library.iter().map(|c| c.total_videos()).sum::<usize>()
);
}
fn cards_take(&mut self) -> Vec<Card> {
let key = (
self.search.clone(),
self.sort_mode.clone(),
self.sidebar_view.clone(),
self.library_generation,
);
if self.cards_cache_key.as_ref() != Some(&key) {
self.cards_cache = self.compute_cards();
self.cards_cache_key = Some(key);
}
std::mem::take(&mut self.cards_cache)
}
fn compute_cards(&self) -> Vec<Card> {
let query = self.search.trim().to_lowercase();
let mut cards = Vec::new();
let add_video = |cards: &mut Vec<Card>, ch_name: &str, v: &library::Video| {
// Search matches title / id / channel name. Description matching
// would require reading the .description sidecar per-video on every
// keystroke — punted to a future "load descriptions into the
// search index on rescan" pass if users ask for it.
if !query.is_empty()
&& !v.title.to_lowercase().contains(&query)
&& !v.id.to_lowercase().contains(&query)
&& !ch_name.to_lowercase().contains(&query)
{
return;
}
let resume_pos = self.resume_positions.get(&v.id).copied();
cards.push(Card {
channel_name: ch_name.to_string(),
title: v.title.clone(),
id: v.id.clone(),
video_path: v.video_path.clone(),
thumb_path: v.thumb_path.clone(),
has_live_chat: v.has_live_chat,
duration_secs: v.duration_secs,
file_size: v.file_size,
upload_date: v.upload_date.clone(),
mtime_unix: v.mtime_unix,
watched: self.watched.contains(&v.id),
bookmark: self.flags.bookmark.contains(&v.id),
favourite: self.flags.favourite.contains(&v.id),
waiting: self.flags.waiting.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())) {
if self.resume_positions.contains_key(&v.id) {
add_video(&mut cards, &ch.name, v);
}
}
}
cards.sort_by(|a, b| {
b.resume_pos.unwrap_or(0.0)
.partial_cmp(&a.resume_pos.unwrap_or(0.0))
.unwrap_or(std::cmp::Ordering::Equal)
});
return cards;
}
SidebarView::Recent => {
// Most-recently-modified videos across the whole library.
// Cap at 100 entries so the grid stays responsive for users
// with thousands of files.
for ch in &self.library {
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
if v.mtime_unix.is_some() {
add_video(&mut cards, &ch.name, v);
}
}
}
cards.sort_by(|a, b| b.mtime_unix.unwrap_or(0).cmp(&a.mtime_unix.unwrap_or(0)));
cards.truncate(100);
return cards;
}
SidebarView::Favourites | SidebarView::Bookmarks | SidebarView::Waiting => {
// Smart folders. Filter the whole library by the matching
// flag bundle set.
let set = match self.sidebar_view {
SidebarView::Favourites => &self.flags.favourite,
SidebarView::Bookmarks => &self.flags.bookmark,
SidebarView::Waiting => &self.flags.waiting,
_ => unreachable!("outer match guarantees the variant"),
};
for ch in &self.library {
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
if set.contains(&v.id) {
add_video(&mut cards, &ch.name, v);
}
}
}
return cards;
}
SidebarView::All => {
for ch in &self.library {
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
add_video(&mut cards, &ch.name, v);
}
}
}
SidebarView::Channel(ci) => {
if let Some(ch) = self.library.get(*ci) {
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
add_video(&mut cards, &ch.name, v);
}
}
}
SidebarView::Playlist(ci, pi) => {
if let Some(ch) = self.library.get(*ci) {
if let Some(pl) = ch.playlists.get(*pi) {
for v in &pl.videos {
add_video(&mut cards, &ch.name, v);
}
}
}
}
}
match self.sort_mode {
SortMode::Title => cards.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())),
SortMode::DurationAsc => cards.sort_by(|a, b| {
a.duration_secs.unwrap_or(0.0)
.partial_cmp(&b.duration_secs.unwrap_or(0.0))
.unwrap_or(std::cmp::Ordering::Equal)
}),
SortMode::DurationDesc => cards.sort_by(|a, b| {
b.duration_secs.unwrap_or(0.0)
.partial_cmp(&a.duration_secs.unwrap_or(0.0))
.unwrap_or(std::cmp::Ordering::Equal)
}),
SortMode::SizeAsc => cards.sort_by_key(|c| c.file_size.unwrap_or(0)),
SortMode::SizeDesc => {
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)> {
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> {
if let Some(slot) = self.textures.get(path) {
return slot.clone();
}
let pb = path.to_path_buf();
if self.thumb_pending.insert(pb.clone()) {
let _ = self.thumb_request_tx.send(pb);
}
None
}
fn description(&mut self, video: &Video) -> String {
let Some(path) = &video.description_path else {
return "(no description file)".to_string();
};
if let Some(text) = self.desc_cache.get(path) {
return text.clone();
}
let text = std::fs::read_to_string(path)
.unwrap_or_else(|e| format!("(could not read description: {e})"));
self.desc_cache.insert(path.clone(), text.clone());
text
}
/// Pick a random unwatched, downloaded video and open it in the
/// configured player. Mirrors the web 🎲 shuffle. Falls back to
/// "any downloaded video" when everything's already been watched.
fn shuffle_play(&mut self) {
use rand::seq::SliceRandom;
// Collect (video_path, id) of every downloaded video, partitioned by
// watched status. We borrow library briefly, then drop before calling
// play_with_tracking (which takes &mut self).
let (unwatched, all): (Vec<(PathBuf, String)>, Vec<(PathBuf, String)>) = {
let mut unwatched = Vec::new();
let mut all = Vec::new();
for ch in &self.library {
for v in ch.all_videos() {
if let Some(p) = &v.video_path {
all.push((p.clone(), v.id.clone()));
if !self.watched.contains(&v.id) {
unwatched.push((p.clone(), v.id.clone()));
}
}
}
}
(unwatched, all)
};
let mut rng = rand::thread_rng();
let pick = if !unwatched.is_empty() {
unwatched.choose(&mut rng).cloned()
} else if !all.is_empty() {
self.status = "Everything is watched — playing a random one anyway".to_string();
all.choose(&mut rng).cloned()
} else {
None
};
if let Some((path, id)) = pick {
self.selected_video = Some(id.clone());
self.play_with_tracking(&path, id);
} else {
self.status = "No downloaded videos to shuffle from".to_string();
}
}
fn play_with_tracking(&mut self, path: &Path, video_id: String) {
let cmd = self.config.player.command.clone();
// 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");
let mut child_cmd = Command::new(&cmd);
#[cfg(unix)]
if use_mpv_ipc {
child_cmd.arg(format!("--input-ipc-server={sock_path}"));
}
if let Some(&pos) = self.resume_positions.get(&video_id) {
if pos > 5.0 && use_mpv_ipc {
child_cmd.arg(format!("--start={}", pos as u64));
}
}
child_cmd.arg(path);
match child_cmd.spawn() {
Ok(_) => {
self.status = format!("Playing {}", file_label(path));
self.currently_playing = Some(video_id.clone());
#[cfg(unix)]
if use_mpv_ipc {
let (tx, rx) = std::sync::mpsc::channel();
self.mpv_rx = Some(rx);
let id = video_id.clone();
std::thread::spawn(move || {
spawn_mpv_tracker(sock_path, id, tx);
});
}
}
Err(_) => {
#[cfg(target_os = "macos")]
let fallback = "open";
#[cfg(not(target_os = "macos"))]
let fallback = "xdg-open";
match Command::new(fallback).arg(path).spawn() {
Ok(_) => self.status = format!("Opened {} in default player", file_label(path)),
Err(e) => self.status = format!("Couldn't open {}: {e}", file_label(path)),
}
}
}
}
fn open_in_file_manager(&mut self, path: &Path) {
let target = if path.is_dir() { path } else { path.parent().unwrap_or(path) };
#[cfg(target_os = "macos")]
let cmd = "open";
#[cfg(not(target_os = "macos"))]
let cmd = "xdg-open";
if let Err(e) = Command::new(cmd).arg(target).spawn() {
self.status = format!("Couldn't open folder: {e}");
}
}
fn toggle_watched(&mut self, video_id: &str) {
let now_watched = !self.watched.contains(video_id);
if let Ok(()) = self.db.set_watched(video_id, now_watched) {
if now_watched {
self.watched.insert(video_id.to_string());
} else {
self.watched.remove(video_id);
}
}
}
/// Flip a per-video flag (`"bookmark"`, `"favourite"`, `"waiting"`, or
/// `"archive"`) and persist the change. Unknown flag names are ignored
/// rather than panic so a typo surfaces in the UI as a no-op.
fn toggle_video_flag(&mut self, video_id: &str, flag: &'static str) {
let set = match flag {
"bookmark" => &mut self.flags.bookmark,
"favourite" => &mut self.flags.favourite,
"waiting" => &mut self.flags.waiting,
"archive" => &mut self.flags.archive,
_ => return,
};
let now_set = !set.contains(video_id);
if let Err(e) = self.db.set_video_flag(video_id, flag, now_set) {
self.status = format!("Flag {flag}: {e}");
return;
}
if now_set { set.insert(video_id.to_string()); } else { set.remove(video_id); }
// Bust the cards cache so the action icons + smart-folder counts
// refresh on the next paint.
self.cards_cache_key = None;
}
fn start_web_server(&mut self) {
if self.web_server_running {
return;
}
let shutdown = crate::web::run_with_shutdown(self.config.clone());
self.web_server_shutdown = Some(shutdown);
self.web_server_running = true;
let port = self.config.web.port;
self.status = format!("Web server started on port {port}. Access at http://localhost:{port}");
}
fn stop_web_server(&mut self) {
if !self.web_server_running {
return;
}
if let Some(shutdown) = self.web_server_shutdown.take() {
let _ = shutdown.send(());
}
self.web_server_running = false;
self.status = "Web server stopped.".to_string();
}
fn bulk_mark_watched(&mut self, watched: bool) {
let ids: Vec<String> = self.bulk_selected.iter().cloned().collect();
for id in &ids {
if let Ok(()) = self.db.set_watched(id, watched) {
if watched {
self.watched.insert(id.clone());
} else {
self.watched.remove(id);
}
}
}
self.bulk_selected.clear();
self.status = format!(
"{} {} as {}watched",
ids.len(),
if ids.len() == 1 { "video" } else { "videos" },
if watched { "" } else { "un" }
);
}
/// Bulk-apply a single per-video flag (`"bookmark"` / `"favourite"` /
/// `"waiting"` / `"archive"`) across the current selection. Same shape
/// as [`bulk_mark_watched`], extended for the smart-folder flag set.
fn bulk_set_flag(&mut self, flag: &'static str, value: bool) {
let ids: Vec<String> = self.bulk_selected.iter().cloned().collect();
let count = ids.len();
for id in &ids {
if self.db.set_video_flag(id, flag, value).is_err() { continue; }
let set = match flag {
"bookmark" => &mut self.flags.bookmark,
"favourite" => &mut self.flags.favourite,
"waiting" => &mut self.flags.waiting,
"archive" => &mut self.flags.archive,
_ => return,
};
if value { set.insert(id.clone()); } else { set.remove(id); }
}
self.bulk_selected.clear();
self.cards_cache_key = None;
self.status = format!(
"{count} {} {}{flag}",
if count == 1 { "video" } else { "videos" },
if value { "" } else { "un" },
);
}
fn run_scheduled_check(&mut self) {
// Snapshot each channel's URL + options first so we don't hold an
// immutable borrow of `self.library` while calling
// `self.downloader.start` (which takes &mut self).
let scheduled: Vec<(String, crate::download_options::DownloadOptions)> = self.library.iter()
.map(|ch| (crate::downloader::recheck_url(ch), ch.download_options.clone()))
.collect();
let count = scheduled.len();
for (url, opts) in scheduled {
let info = classify_url(&url);
// Channel options' quality override (if any) wins over the
// hard-coded Best for scheduled re-checks.
let quality = opts.quality.unwrap_or(DownloadQuality::Best);
self.downloader.start(url, &info, true, quality, false, Some(&opts));
}
self.status = format!("Scheduled check: started {} channel downloads", count);
}
fn check_notifications(&mut self) {
let jobs = &self.downloader.jobs;
let mut finished: Vec<(String, bool)> = Vec::new();
for (i, job) in jobs.iter().enumerate() {
let prev = self.prev_job_states.get(&i).copied();
if prev == Some(JobState::Running) && job.state != JobState::Running {
finished.push((job.label.clone(), job.state == JobState::Done));
}
}
// Rebuild snapshot
self.prev_job_states = jobs.iter().enumerate()
.map(|(i, j)| (i, j.state))
.collect();
for (label, ok) in finished {
let summary = if ok {
format!("Download complete: {label}")
} else {
format!("Download failed: {label}")
};
let _ = notify_rust::Notification::new()
.summary("yt-offline")
.body(&summary)
.timeout(notify_rust::Timeout::Milliseconds(4000))
.show();
}
}
fn channel_total_size(ch: &library::Channel) -> u64 {
ch.total_size_cached
}
fn top_bar(&mut self, ctx: &egui::Context) {
egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
ui.add_space(2.0);
ui.horizontal(|ui| {
ui.heading("yt-offline");
ui.separator();
ui.label("🔍");
ui.add(
egui::TextEdit::singleline(&mut self.search)
.hint_text("filter by title or id")
.desired_width(200.0),
);
if !self.search.is_empty() && ui.button("").on_hover_text("clear").clicked() {
self.search.clear();
}
ui.separator();
ui.label("Size:");
ui.add(
egui::Slider::new(&mut self.card_density, 0.5_f32..=2.0_f32)
.show_value(false)
.step_by(0.1),
);
ui.separator();
if ui.button("⟳ Rescan").clicked() {
self.rescan();
}
if ui.button("🎲 Shuffle")
.on_hover_text("Play a random unwatched downloaded video")
.clicked()
{
self.shuffle_play();
}
let dl_label = if self.show_downloads { "⬇ Downloads ▸" } else { "⬇ Downloads" };
if ui.selectable_label(self.show_downloads, dl_label).clicked() {
self.show_downloads = !self.show_downloads;
}
if ui.selectable_label(self.show_stats, "📊 Stats").clicked() {
self.show_stats = !self.show_stats;
if self.show_stats {
self.stats_report = Some(crate::stats::build(
&self.library,
&self.watched,
&self.resume_positions,
crate::stats::now_unix(),
));
}
}
if ui.selectable_label(self.show_maintenance, "🩺 Maintenance").clicked() {
self.show_maintenance = !self.show_maintenance;
if self.show_maintenance {
self.health_report =
Some(crate::maintenance::scan(&self.library_root, &self.library));
}
}
if ui.selectable_label(self.show_settings, "⚙ Settings").clicked() {
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.settings_source_url = self.config.web.source_url.clone().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 =
self.db.get_setting("password_hash").ok().flatten().is_some();
self.settings_password_input.clear();
self.settings_cookies_input.clear();
let (exists, n) = crate::web::cookies_status();
self.settings_cookies_status = if exists {
format!("{n} cookie(s) loaded")
} else {
"no cookies.txt".to_string()
};
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(egui::RichText::new(&self.status).weak());
});
});
ui.add_space(2.0);
});
}
fn channel_panel(&mut self, ctx: &egui::Context) {
egui::SidePanel::left("channels")
.resizable(true)
.default_width(230.0)
.show(ctx, |ui| {
ui.add_space(4.0);
ui.heading("Channels");
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
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,
format!("⊞ All ({total})"),
)
.clicked()
{
self.sidebar_view = SidebarView::All;
self.selected_video = None;
}
if resume_count > 0 {
if ui
.selectable_label(
self.sidebar_view == SidebarView::ContinueWatching,
format!("▶ Continue Watching ({resume_count})"),
)
.clicked()
{
self.sidebar_view = SidebarView::ContinueWatching;
self.selected_video = None;
}
}
// Recent additions across the whole library — capped at 100
// in `compute_cards`. Only shown when the library has
// anything dated; on a brand-new install this stays hidden.
let has_dated = self.library.iter()
.flat_map(|c| c.all_videos())
.any(|v| v.mtime_unix.is_some());
if has_dated && ui
.selectable_label(
self.sidebar_view == SidebarView::Recent,
"🕒 Recent additions",
)
.clicked()
{
self.sidebar_view = SidebarView::Recent;
self.selected_video = None;
}
// Smart-folder sidebar entries — each shown only when at
// least one video carries the matching flag. Keeps a
// brand-new install free of empty rows.
let fav_count = self.flags.favourite.len();
if fav_count > 0 && ui
.selectable_label(
self.sidebar_view == SidebarView::Favourites,
format!("★ Favourites ({fav_count})"),
)
.clicked()
{
self.sidebar_view = SidebarView::Favourites;
self.selected_video = None;
}
let bmk_count = self.flags.bookmark.len();
if bmk_count > 0 && ui
.selectable_label(
self.sidebar_view == SidebarView::Bookmarks,
format!("🔖 Bookmarks ({bmk_count})"),
)
.clicked()
{
self.sidebar_view = SidebarView::Bookmarks;
self.selected_video = None;
}
let wait_count = self.flags.waiting.len();
if wait_count > 0 && ui
.selectable_label(
self.sidebar_view == SidebarView::Waiting,
format!("⏳ Waiting ({wait_count})"),
)
.clicked()
{
self.sidebar_view = SidebarView::Waiting;
self.selected_video = None;
}
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
let mut pending_ch_download: Option<(String, String)> = None; // (url, channel_name)
// Same trick for the "Channel options…" menu item — capture
// the channel index, open the dialog after the loop so we
// don't recursively borrow `self` from inside the closure.
let mut pending_open_options: Option<usize> = None;
let mut pending_move_to_folder: Option<usize> = None;
let mut pending_open_folder_manager = false;
// Build an ordered list of (header-or-channel-index) items
// so folders render above platform sections. Channels with
// a folder_id appear only in their folder; unfiled channels
// still get the per-platform grouping.
enum SidebarItem { FolderHeader(String, usize), FolderManageBtn, PlatformHeader(Platform), Channel(usize) }
let mut items: Vec<SidebarItem> = Vec::new();
let has_any_folder_or_assignment = !self.folders.is_empty()
|| self.library.iter().any(|c| c.folder_id.is_some());
if has_any_folder_or_assignment {
items.push(SidebarItem::FolderManageBtn);
for f in &self.folders {
let members: Vec<usize> = self.library.iter().enumerate()
.filter(|(_, c)| c.folder_id == Some(f.id))
.map(|(i, _)| i)
.collect();
items.push(SidebarItem::FolderHeader(f.name.clone(), members.len()));
for i in members { items.push(SidebarItem::Channel(i)); }
}
} else {
items.push(SidebarItem::FolderManageBtn);
}
let mut last_platform_marker: Option<Platform> = None;
for (i, ch) in self.library.iter().enumerate() {
if ch.folder_id.is_some() { continue; }
if Some(ch.platform) != last_platform_marker {
items.push(SidebarItem::PlatformHeader(ch.platform));
last_platform_marker = Some(ch.platform);
}
items.push(SidebarItem::Channel(i));
}
for item in items {
match item {
SidebarItem::FolderManageBtn => {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("📁 Folders").small().weak());
if ui.small_button("manage").clicked() {
pending_open_folder_manager = true;
}
});
}
SidebarItem::FolderHeader(name, count) => {
ui.label(
egui::RichText::new(format!(" {name} ({count})"))
.small()
.weak(),
);
}
SidebarItem::PlatformHeader(p) => {
ui.label(
egui::RichText::new(format!("{} {}", p.icon(), p.display_name()))
.small()
.weak(),
);
}
SidebarItem::Channel(i) => {
let (name, total, has_playlists, size_bytes, channel_url, platform) = {
let ch = &self.library[i];
// Prefer the stored `.source-url` over folder-name guessing so
// re-checks work across platforms (and across legacy YouTube
// libraries where the URL was never recorded).
let url = crate::downloader::recheck_url(ch);
(
ch.name.clone(),
ch.total_videos(),
!ch.playlists.is_empty(),
Self::channel_total_size(ch),
url,
ch.platform,
)
};
let is_ch_selected = matches!(self.sidebar_view, SidebarView::Channel(ci) if ci == i)
|| matches!(self.sidebar_view, SidebarView::Playlist(ci, _) if ci == i);
let size_str = if size_bytes > 0 {
format!(" · {}", format_size(size_bytes))
} else {
String::new()
};
// Show platform icon for non-YouTube channels so the sidebar
// reads as a unified library while still making the source
// obvious at a glance.
let prefix = if platform == Platform::YouTube {
String::new()
} else {
format!("{} ", platform.icon())
};
let label = format!("{prefix}{} ({}{})", name, total, size_str);
let ch_selected_no_pl = matches!(self.sidebar_view, SidebarView::Channel(ci) if ci == i);
let resp = ui
.selectable_label(ch_selected_no_pl, label)
.on_hover_text(format!(
"{}\n{}",
self.library[i].path.display(),
platform.display_name(),
));
if resp.clicked() {
self.sidebar_view = SidebarView::Channel(i);
self.selected_video = None;
}
// Right-click context menu
let url_for_menu = channel_url.clone();
let name_for_menu = name.clone();
let channel_idx = i;
resp.context_menu(|ui| {
{
let url = &url_for_menu;
if ui.button("⬇ Check for new videos").clicked() {
pending_ch_download = Some((url.clone(), name_for_menu.clone()));
ui.close_menu();
}
}
if ui.button("⚙ Channel options…").clicked() {
pending_open_options = Some(channel_idx);
ui.close_menu();
}
if ui.button("📁 Move to folder…").clicked() {
pending_move_to_folder = Some(channel_idx);
ui.close_menu();
}
if ui.button("📁 Open folder").clicked() {
let path = self.library[i].path.clone();
if let Err(e) = std::process::Command::new("xdg-open").arg(&path).spawn() {
eprintln!("xdg-open: {e}");
}
ui.close_menu();
}
});
if is_ch_selected && has_playlists {
let playlist_count = self.library[i].playlists.len();
for pi in 0..playlist_count {
let (pl_name, pl_len) = {
let pl = &self.library[i].playlists[pi];
(pl.name.clone(), pl.videos.len())
};
let is_pl = matches!(self.sidebar_view, SidebarView::Playlist(ci, pli) if ci == i && pli == pi);
let pl_label = format!("{} ({})", pl_name, pl_len);
if ui.selectable_label(is_pl, pl_label).clicked() {
self.sidebar_view = SidebarView::Playlist(i, pi);
self.selected_video = None;
}
}
}
} // close SidebarItem::Channel arm
} // close match item
} // close for item in items
if let Some(ci) = pending_open_options {
if let Some(ch) = self.library.get(ci) {
self.channel_options_target = Some((ch.platform, ch.name.clone()));
self.channel_options_form = Self::options_to_form(&ch.download_options);
self.show_channel_options = true;
}
}
if let Some(ci) = pending_move_to_folder {
if let Some(ch) = self.library.get(ci) {
self.move_to_folder_target = Some((ch.platform, ch.name.clone()));
self.show_move_to_folder = true;
}
}
if pending_open_folder_manager {
self.show_folder_manager = true;
self.folder_create_buffer.clear();
}
// Process deferred right-click download action. The
// channel's own options + quality override apply for
// user-triggered re-checks just like for scheduled ones.
if let Some((url, ch_name)) = pending_ch_download {
let info = classify_url(&url);
let opts = self.library.iter()
.find(|c| c.name == ch_name)
.map(|c| c.download_options.clone())
.unwrap_or_default();
let quality = opts.quality.unwrap_or(DownloadQuality::Best);
self.downloader.start(url, &info, !self.dl_full_scan, quality, false, Some(&opts));
self.status = format!("Checking {} for new videos…", ch_name);
}
});
});
}
fn downloads_panel(&mut self, ctx: &egui::Context) {
egui::SidePanel::right("downloads")
.resizable(true)
.default_width(380.0)
.show(ctx, |ui| {
ui.add_space(4.0);
ui.heading("New download");
ui.label("Video, playlist, or channel URL:");
ui.add(
egui::TextEdit::singleline(&mut self.dl_url)
.hint_text("https://www.youtube.com/…")
.desired_width(f32::INFINITY),
);
let info = classify_url(self.dl_url.trim());
let plat_dir = info.platform.dir_name();
let (type_label, dest_preview) = match &info.kind {
UrlKind::Channel { handle } => {
("Channel", format!("{plat_dir}/{handle}/"))
}
UrlKind::Playlist => ("Playlist", format!("{plat_dir}/<creator>/<playlist>/")),
UrlKind::Video => ("Video", format!("{plat_dir}/<creator>/")),
UrlKind::Unknown => ("", String::new()),
};
if !self.dl_url.trim().is_empty() {
ui.horizontal(|ui| {
ui.label("Source:");
ui.strong(format!("{} {}", info.platform.icon(), info.platform.display_name()));
ui.separator();
ui.label("Type:");
ui.strong(type_label);
});
if !dest_preview.is_empty() {
ui.label(egui::RichText::new(&dest_preview).small().weak());
}
}
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.");
ui.checkbox(&mut self.dl_live, "🔴 Live stream (record from start)")
.on_hover_text(
"Use for Twitch/YouTube Live broadcasts. Adds --live-from-start \
so yt-dlp records from the beginning instead of joining mid-stream. \
Also waits if the stream hasn't begun yet.",
);
if info.platform == Platform::Twitch
&& matches!(info.kind, UrlKind::Channel { .. })
{
ui.checkbox(&mut self.dl_twitch_clips, "Clips only (Twitch)")
.on_hover_text(
"Pull only the channel's Clips section instead of the \
default VOD/highlights mix. Rewrites the URL to \
twitch.tv/<channel>/clips before submitting.",
);
}
}
let ready = !self.dl_url.trim().is_empty();
if ui.add_enabled(ready, egui::Button::new("⬇ Start download")).clicked() {
let mut url = self.dl_url.trim().to_string();
// Twitch clips-only: rewrite `twitch.tv/<user>` to
// `twitch.tv/<user>/clips`. yt-dlp's TwitchClips extractor
// handles the rest and we still classify it as Channel so
// the output folder is unchanged.
if self.dl_twitch_clips
&& info.platform == Platform::Twitch
&& matches!(info.kind, UrlKind::Channel { .. })
&& !url.contains("/clips")
{
url = format!("{}/clips", url.trim_end_matches('/'));
}
let dest = dest_preview.clone();
if self.dl_music_mode {
self.downloader.start_music(url);
self.status = "Downloading music…".to_string();
} else {
// Explicit submit: user already chose quality/live in
// the dialog. We don't know which channel the URL
// belongs to yet (yt-dlp resolves it), so no
// channel-options lookup — caller-side overrides
// win.
self.downloader.start(url, &info, !self.dl_full_scan, self.dl_quality, self.dl_live, None);
self.status = format!("Downloading: {dest}");
}
}
ui.separator();
ui.horizontal(|ui| {
ui.heading("Jobs");
if !self.downloader.jobs.is_empty()
&& !self.downloader.any_running()
&& ui.button("Clear finished").clicked()
{
self.downloader.jobs.retain(|j| j.state == JobState::Running);
self.prev_job_states.clear();
}
});
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();
for i in (0..n).rev() {
let job = &self.downloader.jobs[i];
let (text, color) = match job.state {
JobState::Running => ("running", egui::Color32::from_rgb(230, 200, 60)),
JobState::Done => ("done", egui::Color32::from_rgb(110, 200, 110)),
JobState::Failed => ("failed", egui::Color32::from_rgb(220, 110, 110)),
};
let finished = job.state != JobState::Running;
ui.push_id(i, |ui| {
ui.group(|ui| {
ui.horizontal(|ui| {
ui.colored_label(color, text);
ui.label(&job.label);
if finished {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("").clicked() {
remove_job = Some(i);
}
});
}
});
ui.label(egui::RichText::new(&job.url).small().weak());
if job.state == JobState::Running {
ui.add(egui::ProgressBar::new(job.progress).show_percentage());
}
let last = job.log.back().map(String::as_str).unwrap_or("");
if !last.is_empty() {
ui.label(egui::RichText::new(last).small().monospace());
}
ui.collapsing("output log", |ui| {
egui::ScrollArea::vertical()
.max_height(180.0)
.auto_shrink([false, true])
.stick_to_bottom(true)
.show(ui, |ui| {
for line in &self.downloader.jobs[i].log {
ui.label(egui::RichText::new(line).small().monospace());
}
});
});
});
});
}
});
if let Some(i) = remove_job {
self.downloader.remove_job(i);
}
});
}
fn maintenance_window(&mut self, ctx: &egui::Context) {
if !self.show_maintenance {
return;
}
let mut open = self.show_maintenance;
let report = self.health_report.clone().unwrap_or_default();
// Actions are collected during rendering and applied after the closure
// to avoid borrowing `self` while the report is borrowed immutably.
let mut to_remove: Vec<PathBuf> = Vec::new();
let mut to_repair: Vec<String> = Vec::new();
let mut rescan_health = false;
egui::Window::new("🩺 Library health")
.open(&mut open)
.collapsible(false)
.resizable(true)
.default_width(620.0)
.default_height(500.0)
.show(ctx, |ui| {
if ui.button("⟳ Rescan health").clicked() {
rescan_health = true;
}
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
ui.heading(format!("Duplicates ({})", report.duplicates.len()));
if report.duplicates.is_empty() {
ui.label(egui::RichText::new("No duplicate video IDs found.").weak());
}
for g in &report.duplicates {
ui.group(|ui| {
ui.label(
egui::RichText::new(format!("{} [{}]", g.title, g.id)).strong(),
);
for c in &g.copies {
let size = c
.file_size
.map(format_size)
.unwrap_or_else(|| "no video".to_string());
let tag = if c.recommended_keep { "✓ keep" } else { "✗ remove" };
ui.label(format!(
" {} · {} · {} files — {}",
c.location, size, c.files.len(), tag
));
}
if ui.button("🗑 Remove non-recommended copies").clicked() {
for c in &g.copies {
if !c.recommended_keep {
to_remove.extend(c.files.iter().cloned());
}
}
}
});
}
ui.add_space(10.0);
ui.heading(format!("Missing assets ({})", report.missing.len()));
if report.missing.is_empty() {
ui.label(
egui::RichText::new(
"Every video has its thumbnail, metadata, and description.",
)
.weak(),
);
}
if report.missing.len() > 1
&& ui
.button(format!("⬇ Fetch all missing ({})", report.missing.len()))
.clicked()
{
for m in &report.missing {
to_repair.push(m.id.clone());
}
}
for m in &report.missing {
ui.horizontal(|ui| {
let need: Vec<&str> = [
m.missing_thumbnail.then_some("thumbnail"),
m.missing_info.then_some("metadata"),
m.missing_description.then_some("description"),
]
.into_iter()
.flatten()
.collect();
ui.label(format!("{} — missing {}", m.title, need.join(", ")));
if ui.button("⬇ Fetch").clicked() {
to_repair.push(m.id.clone());
}
});
}
});
});
self.show_maintenance = open;
// ── Apply collected actions ────────────────────────────────────────
let mut changed = false;
if !to_remove.is_empty() {
let (removed, errors) =
crate::maintenance::remove_files(&self.library_root, &to_remove);
self.status = if errors.is_empty() {
format!("Removed {removed} file(s)")
} else {
format!("Removed {removed} file(s), {} error(s)", errors.len())
};
changed = true;
}
if !to_repair.is_empty() {
for id in &to_repair {
if let Some((dir, stem)) = crate::maintenance::locate(&self.library, id) {
self.downloader.repair(id, &dir, &stem);
}
}
self.status = format!("Queued {} repair(s) — see Downloads", to_repair.len());
self.show_downloads = true;
}
if changed || rescan_health {
self.rescan();
self.health_report =
Some(crate::maintenance::scan(&self.library_root, &self.library));
}
}
fn stats_window(&mut self, ctx: &egui::Context) {
if !self.show_stats { return; }
let mut open = self.show_stats;
let report = match &self.stats_report {
Some(r) => r.clone(),
None => return,
};
let mut rescan = false;
egui::Window::new("📊 Library statistics")
.open(&mut open)
.collapsible(false)
.resizable(true)
.default_width(560.0)
.default_height(520.0)
.show(ctx, |ui| {
if ui.button("⟳ Recompute").clicked() { rescan = true; }
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
ui.heading("Totals");
egui::Grid::new("stats_totals").num_columns(2).striped(true).show(ui, |ui| {
ui.label("Channels"); ui.label(report.total_channels.to_string()); ui.end_row();
ui.label("Videos"); ui.label(report.total_videos.to_string()); ui.end_row();
ui.label("Playlists"); ui.label(report.total_playlists.to_string()); ui.end_row();
ui.label("Disk used"); ui.label(format_size(report.total_size_bytes)); ui.end_row();
ui.label("Total runtime"); ui.label(format_hours(report.total_duration_secs)); ui.end_row();
ui.label("Watched");
ui.label(format!("{} · {}", report.watched_count, format_hours(report.watched_duration_secs)));
ui.end_row();
ui.label("Continue watching"); ui.label(report.continue_watching_count.to_string()); ui.end_row();
});
ui.add_space(8.0);
ui.heading(format!("Downloads — last {} weeks", report.downloads_per_week.len()));
draw_bars(ui, report.downloads_per_week.iter().map(|w| (
format!("{}", week_label(w.week_start_unix)),
w.count as f32,
format!("{} videos · {}", w.count, format_size(w.size_bytes)),
)));
if !report.videos_per_year.is_empty() {
ui.add_space(8.0);
ui.heading("Videos by upload year");
draw_bars(ui, report.videos_per_year.iter().map(|y| (
y.year.to_string(),
y.count as f32,
format!("{}: {}", y.year, y.count),
)));
}
ui.add_space(8.0);
ui.heading("Top channels by size");
for row in &report.top_channels_by_size {
ui.label(format!(
" {}{} videos · {} · {}",
row.name, row.count, format_size(row.size_bytes),
format_hours(row.duration_secs),
));
}
ui.add_space(8.0);
ui.heading("Top channels by count");
for row in &report.top_channels_by_count {
ui.label(format!(
" {}{} videos · {} · {}",
row.name, row.count, format_size(row.size_bytes),
format_hours(row.duration_secs),
));
}
});
});
self.show_stats = open;
if rescan {
self.stats_report = Some(crate::stats::build(
&self.library, &self.watched, &self.resume_positions, crate::stats::now_unix(),
));
}
}
/// Per-channel download-options editor. Lives as a separate `egui::Window`
/// rather than a modal because egui doesn't really do modals.
fn channel_options_window(&mut self, ctx: &egui::Context) {
if !self.show_channel_options { return; }
let Some((platform, handle)) = self.channel_options_target.clone() else {
self.show_channel_options = false;
return;
};
let mut open = self.show_channel_options;
let mut save = false;
let mut clear = false;
let mut cancel = false;
egui::Window::new(format!("{} · {}", platform.display_name(), handle))
.open(&mut open)
.collapsible(false)
.resizable(true)
.default_width(440.0)
.show(ctx, |ui| {
ui.label(
egui::RichText::new(
"Overrides apply to scheduled re-checks and the right-click \
\"Check for new videos\" action. Explicit downloads from the \
URL bar still use the dialog's own picker.",
)
.small()
.weak(),
);
ui.separator();
let form = &mut self.channel_options_form;
egui::Grid::new("ch_opts_grid").num_columns(2).spacing([12.0, 6.0]).striped(true).show(ui, |ui| {
ui.label("Quality cap");
egui::ComboBox::from_id_salt("ch_opts_quality")
.selected_text(match form.quality_idx {
1 => "Best",
2 => "1080p",
3 => "720p",
4 => "480p",
5 => "360p",
_ => "— Global default —",
})
.show_ui(ui, |ui| {
for (idx, label) in [
(0, "— Global default —"),
(1, "Best"),
(2, "1080p"),
(3, "720p"),
(4, "480p"),
(5, "360p"),
] {
ui.selectable_value(&mut form.quality_idx, idx, label);
}
});
ui.end_row();
ui.label("Audio-only");
ui.checkbox(&mut form.audio_only, "Extract audio (best format)");
ui.end_row();
ui.label("Fetch comments");
ui.checkbox(&mut form.fetch_comments, "Embed --write-comments into info.json")
.on_hover_text("Slow on popular videos. Once captured, comments are browsable from the player modal in the web UI.");
ui.end_row();
ui.label("Bandwidth cap (KB/s)");
ui.add(egui::TextEdit::singleline(&mut form.limit_rate_kb).desired_width(100.0).hint_text("off"));
ui.end_row();
ui.label("Min size (MB)");
ui.add(egui::TextEdit::singleline(&mut form.min_filesize_mb).desired_width(100.0).hint_text("off"));
ui.end_row();
ui.label("Max size (MB)");
ui.add(egui::TextEdit::singleline(&mut form.max_filesize_mb).desired_width(100.0).hint_text("off"));
ui.end_row();
ui.label("Date after (YYYYMMDD)");
ui.add(egui::TextEdit::singleline(&mut form.date_after).desired_width(120.0).hint_text("e.g. 20240101"));
ui.end_row();
});
ui.add_space(6.0);
ui.label("Match filter (yt-dlp --match-filter):");
ui.add(
egui::TextEdit::singleline(&mut self.channel_options_form.match_filter)
.desired_width(f32::INFINITY)
.hint_text("e.g. duration > 60 & view_count > 100")
.font(egui::TextStyle::Monospace),
);
ui.add_space(6.0);
ui.label("Subtitle languages (comma separated, blank = all):");
ui.add(
egui::TextEdit::singleline(&mut self.channel_options_form.subtitle_langs)
.desired_width(f32::INFINITY)
.hint_text("en, ja"),
);
ui.add_space(6.0);
ui.label("Extra yt-dlp args (one per line):");
ui.add(
egui::TextEdit::multiline(&mut self.channel_options_form.extra_args)
.desired_rows(3)
.desired_width(f32::INFINITY)
.hint_text("--no-mtime\n--ignore-config")
.font(egui::TextStyle::Monospace),
);
ui.add_space(8.0);
ui.separator();
ui.horizontal(|ui| {
if ui.button("🗑 Clear all").clicked() { clear = true; }
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Save").clicked() { save = true; }
if ui.button("Cancel").clicked() { cancel = true; }
});
});
});
// Apply the decisions outside the closure so we can mutate self.
if save {
let opts = Self::form_to_options(&self.channel_options_form);
let platform_dir = platform.dir_name();
let result = if opts.is_empty() {
self.db.delete_channel_options(platform_dir, &handle)
} else {
match serde_json::to_string(&opts) {
Ok(json) => self.db.set_channel_options(platform_dir, &handle, &json),
Err(e) => {
self.status = format!("Channel options: encode error: {e}");
Ok(())
}
}
};
match result {
Ok(()) => {
// Reflect immediately on the in-memory library so the next
// re-check sees the change without waiting for a rescan.
for ch in self.library.iter_mut() {
if ch.platform == platform && ch.name == handle {
ch.download_options = opts.clone();
break;
}
}
self.status = format!("Channel options saved for {handle}");
self.show_channel_options = false;
}
Err(e) => self.status = format!("Channel options: {e}"),
}
} else if clear {
let platform_dir = platform.dir_name();
if let Err(e) = self.db.delete_channel_options(platform_dir, &handle) {
self.status = format!("Channel options: {e}");
} else {
for ch in self.library.iter_mut() {
if ch.platform == platform && ch.name == handle {
ch.download_options = Default::default();
break;
}
}
self.status = format!("Channel options cleared for {handle}");
self.show_channel_options = false;
}
} else if cancel || !open {
self.show_channel_options = false;
}
}
/// Folder management dialog — create / rename / delete folders.
fn folder_manager_window(&mut self, ctx: &egui::Context) {
if !self.show_folder_manager { return; }
let mut open = self.show_folder_manager;
let mut create_clicked = false;
let mut to_rename: Option<(i64, String)> = None;
let mut to_delete: Option<(i64, String, usize)> = None;
let mut to_check: Option<i64> = None;
egui::Window::new("📁 Manage folders")
.open(&mut open)
.collapsible(false)
.resizable(true)
.default_width(400.0)
.show(ctx, |ui| {
if self.folders.is_empty() {
ui.label(egui::RichText::new("No folders yet — create one below.").weak());
}
let folders = self.folders.clone();
for f in &folders {
let member_count = self.library.iter()
.filter(|c| c.folder_id == Some(f.id))
.count();
ui.horizontal(|ui| {
ui.label(format!("📁 {} ({} channel{})", f.name, member_count, if member_count == 1 { "" } else { "s" }));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("🗑").on_hover_text("Delete folder").clicked() {
to_delete = Some((f.id, f.name.clone(), member_count));
}
if ui.small_button("").on_hover_text("Rename (type new name in field below first)").clicked() {
to_rename = Some((f.id, f.name.clone()));
}
if member_count > 0
&& ui.small_button("").on_hover_text("Check every channel in this folder for new videos").clicked()
{
to_check = Some(f.id);
}
});
});
ui.separator();
}
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut self.folder_create_buffer)
.hint_text("New folder name")
.desired_width(220.0),
);
if ui.button("Create").clicked() { create_clicked = true; }
});
});
if create_clicked {
let name = self.folder_create_buffer.trim();
if !name.is_empty() {
if let Err(e) = self.db.create_folder(name) {
self.status = format!("Create folder: {e}");
} else {
self.folders = self.db.list_folders().unwrap_or_default();
self.folder_create_buffer.clear();
self.status = "Folder created".to_string();
}
}
}
if let Some((id, old_name)) = to_rename {
// Inline rename: stash the id in a small confirm-style flow.
// For brevity in v1 the rename is handled via a system prompt-free
// approach — replace the existing folder name with the create
// buffer if set, otherwise leave it as-is. (Future iteration:
// proper per-folder edit row.)
let new_name = self.folder_create_buffer.trim().to_string();
if !new_name.is_empty() {
if let Err(e) = self.db.rename_folder(id, &new_name) {
self.status = format!("Rename folder: {e}");
} else {
self.folders = self.db.list_folders().unwrap_or_default();
self.status = format!("Renamed '{old_name}' → '{new_name}'");
self.folder_create_buffer.clear();
}
} else {
self.status = format!("Type new name into the field below, then click ✏ on '{old_name}' again");
}
}
if let Some(folder_id) = to_check {
// Check every member channel for new videos, applying that
// channel's own DownloadOptions / quality override just like
// a scheduled re-check would.
let scheduled: Vec<(String, crate::download_options::DownloadOptions)> =
self.library.iter()
.filter(|ch| ch.folder_id == Some(folder_id))
.map(|ch| (crate::downloader::recheck_url(ch), ch.download_options.clone()))
.collect();
let count = scheduled.len();
for (url, opts) in scheduled {
let info = classify_url(&url);
let quality = opts.quality.unwrap_or(DownloadQuality::Best);
self.downloader.start(url, &info, true, quality, false, Some(&opts));
}
self.status = format!("Folder check: started {count} channel download{}", if count == 1 { "" } else { "s" });
}
if let Some((id, name, count)) = to_delete {
// Hard delete — member channels revert to Unfiled.
if let Err(e) = self.db.delete_folder(id) {
self.status = format!("Delete folder: {e}");
} else {
for ch in self.library.iter_mut() {
if ch.folder_id == Some(id) { ch.folder_id = None; }
}
self.folders = self.db.list_folders().unwrap_or_default();
self.status = format!("Deleted '{name}' ({count} channel{} unfiled)", if count == 1 { "" } else { "s" });
}
}
self.show_folder_manager = open;
}
/// Move-to-folder picker — small list of all folders + an "Unfiled"
/// row for clearing the assignment.
fn move_to_folder_window(&mut self, ctx: &egui::Context) {
if !self.show_move_to_folder { return; }
let Some((platform, handle)) = self.move_to_folder_target.clone() else {
self.show_move_to_folder = false;
return;
};
let mut open = self.show_move_to_folder;
let mut pick: Option<Option<i64>> = None;
let current = self.library.iter()
.find(|c| c.platform == platform && c.name == handle)
.and_then(|c| c.folder_id);
let folders = self.folders.clone();
egui::Window::new(format!("📁 Move \"{handle}\""))
.open(&mut open)
.collapsible(false)
.resizable(false)
.default_width(300.0)
.show(ctx, |ui| {
if ui.selectable_label(current.is_none(), "— Unfiled —").clicked() {
pick = Some(None);
}
for f in &folders {
if ui.selectable_label(current == Some(f.id), format!("📁 {}", f.name)).clicked() {
pick = Some(Some(f.id));
}
}
if folders.is_empty() {
ui.label(egui::RichText::new("No folders yet — open Manage folders to create one.").small().weak());
}
});
if let Some(folder_id) = pick {
let platform_dir = platform.dir_name();
match self.db.set_channel_folder(platform_dir, &handle, folder_id) {
Ok(()) => {
for ch in self.library.iter_mut() {
if ch.platform == platform && ch.name == handle {
ch.folder_id = folder_id;
break;
}
}
self.status = match folder_id {
Some(id) => {
let n = self.folders.iter().find(|f| f.id == id).map(|f| f.name.as_str()).unwrap_or("(unknown)");
format!("Moved '{handle}' to '{n}'")
}
None => format!("Moved '{handle}' to Unfiled"),
};
self.show_move_to_folder = false;
}
Err(e) => self.status = format!("Move: {e}"),
}
} else {
self.show_move_to_folder = open;
}
}
fn settings_window(&mut self, ctx: &egui::Context) {
if !self.show_settings {
return;
}
let mut open = self.show_settings;
egui::Window::new("⚙ Settings")
.open(&mut open)
.collapsible(false)
.resizable(false)
.default_width(480.0)
.show(ctx, |ui| {
egui::Grid::new("settings_grid")
.num_columns(2)
.spacing([12.0, 8.0])
.striped(true)
.show(ui, |ui| {
ui.label("Backup directory:");
ui.add(
egui::TextEdit::singleline(&mut self.settings_dir)
.desired_width(300.0)
.hint_text("/path/to/channels"),
);
ui.end_row();
ui.label("Player command:");
ui.add(
egui::TextEdit::singleline(&mut self.config.player.command)
.desired_width(300.0)
.hint_text("mpv"),
);
ui.end_row();
ui.label("Cookie browser:");
let current_browser = self.config.player.browser.clone();
let display = BROWSERS
.iter()
.find(|(id, _)| *id == current_browser)
.map(|(_, label)| *label)
.unwrap_or(current_browser.as_str());
egui::ComboBox::from_id_salt("browser_combo")
.selected_text(display)
.show_ui(ui, |ui| {
for (id, label) in BROWSERS {
if ui
.selectable_label(self.config.player.browser == *id, *label)
.clicked()
{
self.config.player.browser = id.to_string();
self.downloader.browser = id.to_string();
}
}
});
ui.end_row();
ui.label("Theme:");
egui::ComboBox::from_id_salt("theme_combo")
.selected_text(
theme::THEMES
.iter()
.find(|(id, _)| *id == self.config.ui.theme)
.map(|(_, label)| *label)
.unwrap_or("Dark"),
)
.show_ui(ui, |ui| {
for (id, label) in theme::THEMES {
if ui
.selectable_label(self.config.ui.theme == *id, *label)
.clicked()
{
self.config.ui.theme = id.to_string();
theme::apply(ctx, id);
}
}
});
ui.end_row();
ui.label("Auto-check channels:");
ui.checkbox(&mut self.config.scheduler.enabled, "enabled");
ui.end_row();
ui.label("Check interval (hours):");
ui.add(
egui::DragValue::new(&mut self.config.scheduler.interval_hours)
.range(1..=168)
.suffix("h"),
);
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)
.range(1024..=65535),
);
ui.end_row();
ui.label("Bind interface:");
let binds = crate::web::get_available_binds(self.config.web.port);
let selected_label = binds
.iter()
.find(|b| b.id == self.settings_bind_mode)
.map(|b| b.label.clone())
.unwrap_or_else(|| "Localhost only".to_string());
egui::ComboBox::from_id_salt("bind_combo")
.selected_text(selected_label)
.show_ui(ui, |ui| {
for b in &binds {
if ui
.selectable_label(self.settings_bind_mode == b.id, &b.label)
.clicked()
{
self.settings_bind_mode = b.id.clone();
}
}
});
ui.end_row();
ui.label("Download password:");
ui.vertical(|ui| {
ui.checkbox(&mut self.settings_password_enabled, "require for web downloads");
if self.settings_password_enabled {
let hint = if self.settings_password_input.is_empty() {
"leave blank to keep current"
} else {
"set a password"
};
ui.add(
egui::TextEdit::singleline(&mut self.settings_password_input)
.password(true)
.desired_width(220.0)
.hint_text(hint),
);
}
});
ui.end_row();
ui.label("Web server:");
ui.horizontal(|ui| {
if self.web_server_running {
if ui.button("🛑 Stop").clicked() {
self.stop_web_server();
}
ui.label(egui::RichText::new("Running").small().weak());
} else {
if ui.button("▶ Start").clicked() {
self.start_web_server();
}
ui.label(egui::RichText::new("Stopped").small().weak());
}
});
ui.end_row();
ui.label("Cookies:");
ui.vertical(|ui| {
ui.label(egui::RichText::new(&self.settings_cookies_status).small().weak());
if ui.button("📁 Choose cookies.txt…").clicked() {
// Run the file dialog off the UI thread; the chosen
// path comes back via cookies_pick_rx (polled in update()).
let tx = self.cookies_pick_tx.clone();
std::thread::spawn(move || {
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
let picked = rt.block_on(async {
rfd::AsyncFileDialog::new()
.set_title("Select cookies.txt")
.add_filter("cookies", &["txt"])
.pick_file()
.await
});
if let Some(f) = picked {
let _ = tx.send(f.path().to_path_buf());
}
}
});
}
ui.label(egui::RichText::new("…or paste below").small().weak());
ui.add(
egui::TextEdit::multiline(&mut self.settings_cookies_input)
.desired_rows(3)
.desired_width(300.0)
.hint_text("paste Netscape cookies.txt…"),
);
ui.horizontal(|ui| {
if ui.button("Update cookies").clicked() {
match crate::web::write_cookies(&self.settings_cookies_input) {
Ok(n) => {
self.settings_cookies_status = format!("{n} cookie(s) loaded");
self.settings_cookies_input.clear();
self.status = format!("Cookies updated ({n} entries)");
}
Err(e) => self.status = format!("Cookies error: {e}"),
}
}
if ui.button("Clear cookies").clicked() {
match crate::web::clear_cookies() {
Ok(()) => {
self.settings_cookies_status = "no cookies.txt".to_string();
self.status = "Cookies cleared".to_string();
}
Err(e) => self.status = format!("Error clearing cookies: {e}"),
}
}
});
ui.label(
egui::RichText::new("Export via a browser extension, then paste.")
.small()
.weak(),
);
});
ui.end_row();
});
ui.add_space(8.0);
ui.separator();
ui.heading("Backup");
ui.add_space(4.0);
ui.label(
egui::RichText::new(
"Saves a copy of yt-offline.db (watched / favourites / bookmarks / \
waiting / channel-options / folders). Restore by copying it back \
into your channels directory before launch.",
)
.small()
.weak(),
);
if ui.button("💾 Save library backup…").clicked() {
// Open the save dialog off the UI thread, mirroring the
// cookies file-picker pattern.
let tx = self.backup_save_tx.clone();
let default_name = format!(
"yt-offline-{}.db",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs()).unwrap_or(0),
);
std::thread::spawn(move || {
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
let picked = rt.block_on(async {
rfd::AsyncFileDialog::new()
.set_title("Save library backup")
.set_file_name(&default_name)
.add_filter("SQLite database", &["db"])
.save_file()
.await
});
if let Some(f) = picked {
let _ = tx.send(f.path().to_path_buf());
}
}
});
}
ui.add_space(8.0);
ui.separator();
ui.heading("Source code (AGPL §13)");
ui.add_space(4.0);
ui.label("Repository URL (shown in web UI footer):");
ui.add(
egui::TextEdit::singleline(&mut self.settings_source_url)
.hint_text("https://codeberg.org/you/your-fork")
.desired_width(400.0),
);
ui.label(
egui::RichText::new(
"Every network user must be offered a way to obtain the running source code. \
Leave empty to hide the link.",
)
.small()
.weak(),
);
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);
ui.horizontal(|ui| {
if ui.button("Apply & Save").clicked() {
let new_dir = PathBuf::from(&self.settings_dir);
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))
};
// Source URL (AGPL §13)
let src_trimmed = self.settings_source_url.trim();
self.config.web.source_url = if src_trimmed.is_empty() {
None
} else {
Some(src_trimmed.to_string())
};
// 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;
self.config.web.bind = new_bind;
// Apply the download-password setting to the database.
let pwd_result = if !self.settings_password_enabled {
self.db.set_setting("password_hash", None)
} else if !self.settings_password_input.is_empty() {
match crate::web::hash_password(&self.settings_password_input) {
Some(hash) => {
let r = self.db.set_setting("password_hash", Some(&hash));
self.settings_password_input.clear();
r
}
None => {
self.status = "Error hashing password".to_string();
Ok(())
}
}
} else {
Ok(())
};
if let Err(e) = pwd_result {
self.status = format!("DB error: {e}");
}
match self.config.save(&self.config_path) {
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.library_root = new_dir
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| new_dir.clone());
self.downloader.channels_root = new_dir;
let _ = std::fs::create_dir_all(&self.channels_root);
let _ = std::fs::create_dir_all(&self.library_root);
for &p in Platform::all() {
let _ = std::fs::create_dir_all(platform::platform_root(&self.channels_root, p));
}
self.rescan();
}
// Re-bind a running server so the new interface takes effect now.
if bind_changed && self.web_server_running {
self.stop_web_server();
self.start_web_server();
self.status = "Settings saved. Web server re-bound.".to_string();
}
}
ui.label(
egui::RichText::new("Theme previews immediately; other changes apply on save.")
.weak()
.small(),
);
});
});
self.show_settings = open;
}
fn detail_panel(&mut self, ctx: &egui::Context) {
let selected_id = match &self.selected_video {
Some(id) => id.clone(),
None => return,
};
let Some((video, _channel_name)) = self.find_video_by_id(&selected_id) else {
self.selected_video = None;
return;
};
let is_watched = self.watched.contains(&selected_id);
let resume_pos = self.resume_positions.get(&selected_id).copied();
egui::TopBottomPanel::bottom("detail")
.resizable(true)
.default_height(220.0)
.min_height(80.0)
.show(ctx, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.heading(&video.title);
if video.video_path.is_some() {
if ui.button("▶ Play").clicked() {
if let Some(p) = video.video_path.clone() {
self.play_with_tracking(&p, selected_id.clone());
}
}
if let Some(pos) = resume_pos {
if pos > 5.0 {
let label = format!("⏩ Resume ({})", format_duration(pos));
if ui.button(label).clicked() {
if let Some(p) = video.video_path.clone() {
self.play_with_tracking(&p, selected_id.clone());
}
}
if ui.small_button("✖ clear position").clicked() {
let _ = self.db.clear_position(&selected_id);
self.resume_positions.remove(&selected_id);
}
}
}
}
if let Some(p) = video.video_path.clone() {
if ui.button("📁 Show file").clicked() {
self.open_in_file_manager(&p);
}
}
let watched_label = if is_watched { "✓ Watched" } else { "○ Mark watched" };
if ui.button(watched_label).clicked() {
self.toggle_watched(&selected_id);
}
if video.has_live_chat {
ui.label(egui::RichText::new("💬 live chat").small().weak());
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("✖ close").clicked() {
self.selected_video = None;
}
ui.label(
egui::RichText::new(format!("id: {}", video.id))
.monospace()
.weak(),
);
});
});
ui.horizontal(|ui| {
if let Some(date) = video.upload_date.as_deref().map(format_upload_date) {
if !date.is_empty() {
ui.label(
egui::RichText::new(format!("📅 {date}"))
.small()
.weak(),
);
ui.label(egui::RichText::new("·").weak());
}
}
if let Some(secs) = video.duration_secs {
ui.label(egui::RichText::new(format_duration(secs)).small().weak());
ui.label(egui::RichText::new("·").weak());
}
if let Some(bytes) = video.file_size {
ui.label(egui::RichText::new(format_size(bytes)).small().weak());
}
});
ui.separator();
let description = self.description(&video);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add(egui::Label::new(description).wrap());
});
});
}
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(_, _));
// Channel metadata banner
if let SidebarView::Channel(ci) = self.sidebar_view {
if let Some(ch) = self.library.get(ci) {
if let Some(meta) = &ch.meta {
ui.group(|ui| {
ui.horizontal(|ui| {
if let Some(name) = &meta.uploader {
ui.strong(name);
}
if let Some(subs) = meta.subscriber_count {
ui.label(
egui::RichText::new(format!("{} subscribers", format_subs(subs)))
.weak(),
);
}
if let Some(url) = &meta.channel_url {
ui.hyperlink_to("Open on YouTube", url);
}
});
});
}
}
}
// Bulk mode toolbar
ui.horizontal(|ui| {
let label_text = if self.bulk_mode {
format!("{} videos", cards.len())
} else {
format!("{} videos", cards.len())
};
ui.label(label_text);
if !self.search.trim().is_empty() {
ui.label(
egui::RichText::new(format!("(filtered by \"{}\")", self.search.trim())).weak(),
);
}
ui.separator();
if ui.selectable_label(self.bulk_mode, "☑ Select").clicked() {
self.bulk_mode = !self.bulk_mode;
if !self.bulk_mode {
self.bulk_selected.clear();
}
}
if self.bulk_mode && !self.bulk_selected.is_empty() {
ui.separator();
let n = self.bulk_selected.len();
ui.label(format!("{n} selected"));
if ui.button("✓ Watched").clicked() {
self.bulk_mark_watched(true);
self.bulk_mode = false;
}
if ui.button("○ Unwatched").clicked() {
self.bulk_mark_watched(false);
self.bulk_mode = false;
}
if ui.button("★ Favourite").on_hover_text("Mark every selected video as favourite").clicked() {
self.bulk_set_flag("favourite", true);
self.bulk_mode = false;
}
if ui.button("🔖 Bookmark").on_hover_text("Mark every selected video as bookmarked").clicked() {
self.bulk_set_flag("bookmark", true);
self.bulk_mode = false;
}
if ui.button("⏳ Waiting").on_hover_text("Add every selected video to the waiting list").clicked() {
self.bulk_set_flag("waiting", true);
self.bulk_mode = false;
}
}
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");
ui.selectable_value(&mut self.sort_mode, SortMode::DurationAsc, "Shortest");
ui.selectable_value(&mut self.sort_mode, SortMode::Title, "Title");
ui.label(egui::RichText::new("Sort:").weak());
});
});
ui.separator();
if cards.is_empty() {
ui.add_space(20.0);
ui.vertical_centered(|ui| {
ui.label(egui::RichText::new("Nothing here.").weak());
if !matches!(self.sidebar_view, SidebarView::ContinueWatching) {
ui.label(
egui::RichText::new(
"Drop a yt-dlp download into channels/<name>/, or use the Downloads panel.",
)
.small()
.weak(),
);
}
});
return;
}
let density = self.card_density;
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
let thumb_w = (176.0 * density).round();
let thumb_h = (99.0 * density).round();
let thumb_size = egui::vec2(thumb_w, thumb_h);
for card in &cards {
let selected = self.selected_video.as_deref() == Some(card.id.as_str());
let is_playing = self.currently_playing.as_deref() == Some(card.id.as_str());
let bulk_checked = self.bulk_selected.contains(&card.id);
let mut clicked_card = false;
let mut play_card = false;
let mut toggle_watched_card = false;
// Flag toggles deferred outside the row closure so we can
// mutate `self.flags` + DB without fighting the borrow checker.
let mut toggle_flag_card: Option<&'static str> = None;
ui.horizontal(|ui| {
let (rect, resp) = ui.allocate_exact_size(thumb_size, egui::Sense::click());
let texture = card.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, rect);
}
None => {
ui.painter().rect_filled(rect, 4.0, egui::Color32::from_gray(38));
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
"",
egui::FontId::proportional(26.0 * density),
egui::Color32::from_gray(110),
);
}
}
if selected {
ui.painter().rect_stroke(
rect,
4.0,
egui::Stroke::new(2.0, egui::Color32::from_rgb(120, 170, 230)),
);
}
if is_playing {
ui.painter().rect_stroke(
rect,
4.0,
egui::Stroke::new(2.0, egui::Color32::from_rgb(110, 200, 110)),
);
}
if bulk_checked {
ui.painter().rect_stroke(
rect,
4.0,
egui::Stroke::new(3.0, egui::Color32::from_rgb(180, 130, 240)),
);
}
if card.watched {
ui.painter().rect_filled(
egui::Rect::from_min_size(
rect.min,
egui::vec2(rect.width(), rect.height() * 0.18),
),
0.0,
egui::Color32::from_rgba_premultiplied(30, 140, 60, 200),
);
ui.painter().text(
rect.min + egui::vec2(4.0, 2.0),
egui::Align2::LEFT_TOP,
"✓ watched",
egui::FontId::proportional(10.0 * density),
egui::Color32::WHITE,
);
}
if resp.clicked() {
clicked_card = true;
}
if resp.double_clicked() {
play_card = true;
}
ui.vertical(|ui| {
let title_color = if card.watched {
ui.visuals().weak_text_color()
} else {
ui.visuals().text_color()
};
if ui
.selectable_label(
selected,
egui::RichText::new(&card.title)
.strong()
.color(title_color),
)
.clicked()
{
clicked_card = true;
}
ui.horizontal(|ui| {
if show_channel {
ui.label(egui::RichText::new(&card.channel_name).small().weak());
ui.label(egui::RichText::new("·").weak());
}
ui.label(egui::RichText::new(&card.id).small().monospace().weak());
if let Some(date) = card.upload_date.as_deref().map(format_upload_date) {
if !date.is_empty() {
ui.label(egui::RichText::new("·").weak());
ui.label(egui::RichText::new(date).small().weak());
}
}
if let Some(secs) = card.duration_secs {
ui.label(egui::RichText::new("·").weak());
ui.label(egui::RichText::new(format_duration(secs)).small().weak());
}
if let Some(bytes) = card.file_size {
ui.label(egui::RichText::new("·").weak());
ui.label(egui::RichText::new(format_size(bytes)).small().weak());
}
if card.has_live_chat {
ui.label(egui::RichText::new("· 💬").small().weak());
}
if card.video_path.is_none() {
ui.label(
egui::RichText::new("· no video file")
.small()
.color(egui::Color32::from_rgb(200, 140, 90)),
);
}
});
ui.horizontal(|ui| {
if self.bulk_mode {
let chk_label = if bulk_checked { "" } else { "" };
if ui.small_button(chk_label).clicked() {
clicked_card = true; // handled below
}
} else {
if card.video_path.is_some() && ui.small_button("▶ Play").clicked() {
play_card = true;
}
if let Some(pos) = card.resume_pos {
if pos > 5.0 && ui.small_button(format!("{}", format_duration(pos))).clicked() {
play_card = true;
}
}
if ui.small_button("Details").clicked() {
clicked_card = true;
}
let w_label = if card.watched { "" } else { "" };
if ui.small_button(w_label).on_hover_text("Toggle watched").clicked() {
toggle_watched_card = true;
}
let fav_label = if card.favourite { "" } else { "" };
if ui.small_button(fav_label).on_hover_text("Toggle favourite").clicked() {
toggle_flag_card = Some("favourite");
}
let bm_label = if card.bookmark { "🔖" } else { "🔖̲" };
if ui.small_button(bm_label).on_hover_text("Toggle bookmark").clicked() {
toggle_flag_card = Some("bookmark");
}
let wait_label = if card.waiting { "" } else { "" };
if ui.small_button(wait_label).on_hover_text("Toggle waiting").clicked() {
toggle_flag_card = Some("waiting");
}
}
});
});
});
if self.bulk_mode {
if clicked_card {
let id = card.id.clone();
if self.bulk_selected.contains(&id) {
self.bulk_selected.remove(&id);
} else {
self.bulk_selected.insert(id);
}
}
} else if play_card {
if let Some(p) = card.video_path.clone() {
let id = card.id.clone();
self.play_with_tracking(&p, id);
}
self.selected_video = Some(card.id.clone());
} else if clicked_card {
self.selected_video = Some(card.id.clone());
}
if toggle_watched_card {
let id = card.id.clone();
self.toggle_watched(&id);
}
if let Some(flag) = toggle_flag_card {
let id = card.id.clone();
self.toggle_video_flag(&id, flag);
}
ui.separator();
}
});
self.cards_cache = cards;
}
}
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();
if was_running_prev && !any_running {
self.rescan();
}
if any_running {
ctx.request_repaint_after(std::time::Duration::from_millis(250));
}
// Scheduled channel checks
if self.config.scheduler.enabled && !any_running {
// 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 {
self.last_scheduled_check = Some(Instant::now());
self.run_scheduled_check();
}
}
// Poll mpv position updates
if let Some(rx) = &self.mpv_rx {
while let Ok((video_id, pos)) = rx.try_recv() {
let _ = self.db.set_position(&video_id, pos);
self.resume_positions.insert(video_id, pos);
}
}
// Drain any decoded thumbnails from the worker thread
while let Ok((path, img)) = self.thumb_result_rx.try_recv() {
self.thumb_pending.remove(&path);
let handle = img.map(|color_image| {
ctx.load_texture(
path.to_string_lossy(),
color_image,
egui::TextureOptions::LINEAR,
)
});
self.textures.insert(path, handle);
}
// A cookies.txt was chosen in the file dialog — validate and install it.
while let Ok(path) = self.cookies_pick_rx.try_recv() {
match std::fs::read_to_string(&path) {
Ok(content) => match crate::web::write_cookies(&content) {
Ok(n) => {
self.settings_cookies_status = format!("{n} cookie(s) loaded");
self.status = format!("Cookies imported ({n} entries)");
}
Err(e) => self.status = format!("Cookies error: {e}"),
},
Err(e) => self.status = format!("Could not read {}: {e}", path.display()),
}
}
// User picked a backup destination — copy yt-offline.db there.
while let Ok(dest) = self.backup_save_rx.try_recv() {
let src = self.channels_root.join("yt-offline.db");
match std::fs::copy(&src, &dest) {
Ok(bytes) => self.status = format!(
"Backup saved ({}{})",
format_size(bytes),
dest.display(),
),
Err(e) => self.status = format!("Backup failed: {e}"),
}
}
self.top_bar(ctx);
self.channel_panel(ctx);
if self.show_downloads {
self.downloads_panel(ctx);
}
self.settings_window(ctx);
self.maintenance_window(ctx);
self.stats_window(ctx);
self.channel_options_window(ctx);
self.folder_manager_window(ctx);
self.move_to_folder_window(ctx);
self.detail_panel(ctx);
egui::CentralPanel::default().show(ctx, |ui| {
self.video_list(ctx, ui);
});
}
}
#[cfg(unix)]
fn spawn_mpv_tracker(sock_path: String, video_id: String, tx: std::sync::mpsc::Sender<(String, f64)>) {
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::time::Duration;
let mut stream = None;
for _ in 0..20 {
std::thread::sleep(Duration::from_millis(500));
if let Ok(s) = UnixStream::connect(&sock_path) {
stream = Some(s);
break;
}
}
let mut stream = match stream {
Some(s) => s,
None => return,
};
stream.set_read_timeout(Some(Duration::from_secs(3))).ok();
let mut buf = [0u8; 4096];
loop {
std::thread::sleep(Duration::from_secs(5));
let query = b"{\"command\":[\"get_property\",\"time-pos\"]}\n";
if stream.write_all(query).is_err() {
break;
}
match stream.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
for chunk in buf[..n].split(|&b| b == b'\n').filter(|c| !c.is_empty()) {
if let Ok(s) = std::str::from_utf8(chunk) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(s) {
if let Some(pos) = val.get("data").and_then(|v| v.as_f64()) {
if tx.send((video_id.clone(), pos)).is_err() {
return;
}
}
}
}
}
}
Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut => {}
Err(_) => break,
}
}
}
fn decode_thumbnail_image(path: &Path) -> Option<egui::ColorImage> {
let image = image::open(path).ok()?;
let image = image.thumbnail(384, 216);
let rgba = image.to_rgba8();
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
Some(egui::ColorImage::from_rgba_unmultiplied([w, h], rgba.as_raw()))
}
fn file_label(path: &Path) -> String {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned())
}
fn format_duration(secs: f64) -> String {
let secs = secs as u64;
let h = secs / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") }
}
/// Convert yt-dlp's native `YYYYMMDD` upload-date string into `YYYY-MM-DD`
/// for display. Returns an empty string when input doesn't look like a date
/// so callers can decide whether to skip the row entirely.
fn format_upload_date(yyyymmdd: &str) -> String {
if yyyymmdd.len() < 8 || !yyyymmdd.chars().all(|c| c.is_ascii_digit()) {
return String::new();
}
format!("{}-{}-{}", &yyyymmdd[..4], &yyyymmdd[4..6], &yyyymmdd[6..8])
}
fn format_size(bytes: u64) -> String {
if bytes >= 1_073_741_824 {
format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
} else if bytes >= 1_048_576 {
format!("{:.0} MB", bytes as f64 / 1_048_576.0)
} else {
format!("{:.0} KB", bytes as f64 / 1_024.0)
}
}
/// Format a number of seconds as `Hh Mm` (or `Mm` under 1 hour, `0h` empty).
fn format_hours(secs: f64) -> String {
if secs < 60.0 { return "0h".to_string(); }
let h = (secs / 3600.0) as u64;
let m = ((secs as u64 % 3600) / 60) as u64;
if h > 0 { format!("{h}h {m}m") } else { format!("{m}m") }
}
/// Format the week-start unix time as `M/D` for an axis label.
fn week_label(unix: u64) -> String {
// Reproduce just enough calendar math to render M/D without pulling in a
// chrono dep: count days since 1970-01-01, then walk the year/month table.
let days = (unix / 86_400) as i64;
let (mut y, mut d) = (1970i64, days);
loop {
let ly = is_leap(y as u32);
let yd = if ly { 366 } else { 365 };
if d < yd { break; }
d -= yd; y += 1;
}
let months = if is_leap(y as u32) {
[31,29,31,30,31,30,31,31,30,31,30,31]
} else {
[31,28,31,30,31,30,31,31,30,31,30,31]
};
let mut m = 0usize;
while m < 12 && d >= months[m] as i64 { d -= months[m] as i64; m += 1; }
format!("{}/{}", m as u32 + 1, d + 1)
}
fn is_leap(y: u32) -> bool { (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 }
/// Draw a simple bar chart of `(label, value, tooltip)` rows. Heights scale
/// to the max value in the iterator.
fn draw_bars<I>(ui: &mut egui::Ui, items: I)
where I: IntoIterator<Item = (String, f32, String)>,
{
let rows: Vec<_> = items.into_iter().collect();
if rows.is_empty() { return; }
let max = rows.iter().map(|r| r.1).fold(1.0f32, f32::max);
let chart_h = 70.0;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for (label, value, tip) in &rows {
ui.vertical(|ui| {
let h = (value / max) * chart_h;
let bar_w = 22.0;
let (rect, resp) = ui.allocate_exact_size(
egui::vec2(bar_w, chart_h + 14.0),
egui::Sense::hover(),
);
let bar_rect = egui::Rect::from_min_max(
egui::pos2(rect.min.x, rect.max.y - 14.0 - h),
egui::pos2(rect.max.x, rect.max.y - 14.0),
);
ui.painter().rect_filled(
bar_rect, 2.0,
ui.visuals().selection.bg_fill,
);
ui.painter().text(
egui::pos2(rect.center().x, rect.max.y),
egui::Align2::CENTER_BOTTOM,
label,
egui::FontId::proportional(9.0),
ui.visuals().weak_text_color(),
);
resp.on_hover_text(tip);
});
}
});
}
fn format_subs(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}K", n as f64 / 1_000.0)
} else {
n.to_string()
}
}