diff --git a/src/app.rs b/src/app.rs index 3f438e5..8fcf1f8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,7 +14,8 @@ use eframe::egui; use crate::config::Config; use crate::database::Database; -use crate::downloader::{detect_url_kind, DownloadQuality, Downloader, JobState, UrlKind}; +use crate::downloader::{DownloadQuality, Downloader, JobState}; +use crate::platform::{self, classify_url, Platform, UrlKind}; use crate::library::{self, Video}; use crate::theme; @@ -67,6 +68,10 @@ 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, sidebar_view: SidebarView, selected_video: Option, @@ -145,6 +150,16 @@ impl App { 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 library = library::scan_channels(&channels_root); let status = format!( "{} channels, {} videos", @@ -192,6 +207,7 @@ impl App { config, config_path, channels_root: channels_root.clone(), + library_root, library, sidebar_view: SidebarView::All, selected_video: None, @@ -547,11 +563,11 @@ impl App { fn run_scheduled_check(&mut self) { let mut count = 0; let urls: Vec = self.library.iter() - .map(|ch| crate::downloader::check_url_for_folder(&ch.name)) + .map(|ch| crate::downloader::recheck_url(ch)) .collect(); for url in urls { - let kind = detect_url_kind(&url); - self.downloader.start(url, &kind, true, DownloadQuality::Best); + let info = classify_url(&url); + self.downloader.start(url, &info, true, DownloadQuality::Best); count += 1; } self.status = format!("Scheduled check: started {} channel downloads", count); @@ -636,7 +652,7 @@ impl App { self.show_maintenance = !self.show_maintenance; if self.show_maintenance { self.health_report = - Some(crate::maintenance::scan(&self.channels_root, &self.library)); + Some(crate::maintenance::scan(&self.library_root, &self.library)); } } if ui.selectable_label(self.show_settings, "⚙ Settings").clicked() { @@ -734,17 +750,19 @@ impl App { let mut pending_ch_download: Option<(String, String)> = None; // (url, channel_name) for i in 0..self.library.len() { - let (name, total, has_playlists, size_bytes, channel_url) = { + let (name, total, has_playlists, size_bytes, channel_url, platform) = { let ch = &self.library[i]; - // Always derive the check URL from the folder name so yt-dlp - // writes to the existing folder, not a new UCxxx one. - let url = crate::downloader::check_url_for_folder(&ch.name); + // 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, ) }; @@ -756,12 +774,24 @@ impl App { } else { String::new() }; - let label = format!("{} ({}{})", name, total, size_str); + // 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(self.library[i].path.display().to_string()); + .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; @@ -805,8 +835,8 @@ impl App { // Process deferred right-click download action if let Some((url, ch_name)) = pending_ch_download { - let kind = detect_url_kind(&url); - self.downloader.start(url, &kind, !self.dl_full_scan, DownloadQuality::Best); + let info = classify_url(&url); + self.downloader.start(url, &info, !self.dl_full_scan, DownloadQuality::Best); self.status = format!("Checking {} for new videos…", ch_name); } }); @@ -827,18 +857,22 @@ impl App { .desired_width(f32::INFINITY), ); - let kind = detect_url_kind(self.dl_url.trim()); - let (type_label, dest_preview) = match &kind { + 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!("→ channels/{}/", handle)) + ("Channel", format!("→ {plat_dir}/{handle}/")) } - UrlKind::Playlist => ("Playlist", "→ channels///".to_string()), - UrlKind::Video => ("Video", "→ channels//".to_string()), + UrlKind::Playlist => ("Playlist", format!("→ {plat_dir}///")), + UrlKind::Video => ("Video", format!("→ {plat_dir}//")), 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); }); @@ -877,7 +911,7 @@ impl App { self.downloader.start_music(url); self.status = "Downloading music…".to_string(); } else { - self.downloader.start(url, &kind, !self.dl_full_scan, self.dl_quality); + self.downloader.start(url, &info, !self.dl_full_scan, self.dl_quality); self.status = format!("Downloading: {dest}"); } } @@ -1062,7 +1096,7 @@ impl App { let mut changed = false; if !to_remove.is_empty() { let (removed, errors) = - crate::maintenance::remove_files(&self.channels_root, &to_remove); + crate::maintenance::remove_files(&self.library_root, &to_remove); self.status = if errors.is_empty() { format!("Removed {removed} file(s)") } else { @@ -1082,7 +1116,7 @@ impl App { if changed || rescan_health { self.rescan(); self.health_report = - Some(crate::maintenance::scan(&self.channels_root, &self.library)); + Some(crate::maintenance::scan(&self.library_root, &self.library)); } } @@ -1527,8 +1561,16 @@ impl App { 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. diff --git a/src/downloader.rs b/src/downloader.rs index d6b3cdb..0756e4e 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -30,6 +30,7 @@ use std::process::{Command, Stdio}; use std::sync::mpsc::{channel, Receiver}; use std::thread; +use crate::platform::{self, UrlInfo, UrlKind}; use crate::ytdlp_bin; /// Video quality level passed as a `-f` format selector to yt-dlp. @@ -70,23 +71,14 @@ impl DownloadQuality { } } -/// Describes the kind of YouTube URL being downloaded, which determines the -/// output path template passed to yt-dlp. -pub enum UrlKind { - /// A channel URL (`/@handle`, `/channel/ID`, or `/c/name`). - Channel { handle: String }, - Playlist, - Video, - Unknown, -} - -/// Build a YouTube URL from a library folder name that yt-dlp will resolve to -/// the same folder it already downloaded to. +/// Build a YouTube URL from a legacy library folder name. Used as a fallback +/// when a channel folder has no `.source-url` sidecar (i.e. it predates the +/// multi-platform changes). /// /// Folder names that look like a channel ID (`UC` + 22 chars) use the /// `/channel/` form; everything else is treated as a handle and gets `/@`. /// This avoids the mismatch where info.json's canonical `channel_url` field -/// points to `/channel/UCxxx` and yt-dlp then creates a second folder. +/// points to `/channel/UCxxx` and yt-dlp creates a second folder. pub fn check_url_for_folder(folder_name: &str) -> String { if folder_name.starts_with("UC") && folder_name.len() == 24 { format!("https://www.youtube.com/channel/{folder_name}") @@ -95,32 +87,17 @@ pub fn check_url_for_folder(folder_name: &str) -> String { } } -/// Classify a YouTube URL into a [`UrlKind`] by inspecting its path. -pub fn detect_url_kind(url: &str) -> UrlKind { - if url.contains("playlist?list=") { - return UrlKind::Playlist; +/// Re-check URL for a [`crate::library::Channel`]. Prefers the stored +/// `.source-url` sidecar when present, falls back to the YouTube heuristic +/// for legacy folders. +pub fn recheck_url(ch: &crate::library::Channel) -> String { + if let Some(url) = ch.source_url.as_deref() { + return url.to_string(); } - if let Some(h) = extract_after(url, "/@") { - return UrlKind::Channel { handle: h.to_string() }; - } - if let Some(h) = extract_after(url, "/channel/") { - return UrlKind::Channel { handle: h.to_string() }; - } - if let Some(h) = extract_after(url, "/c/") { - return UrlKind::Channel { handle: h.to_string() }; - } - if url.contains("watch?v=") || url.contains("youtu.be/") { - return UrlKind::Video; - } - UrlKind::Unknown + // Legacy YouTube libraries: rebuild from folder name. + check_url_for_folder(&ch.name) } -fn extract_after<'a>(url: &'a str, marker: &str) -> Option<&'a str> { - let start = url.find(marker)? + marker.len(); - let rest = &url[start..]; - let end = rest.find(|c| c == '/' || c == '?' || c == '&' || c == '#').unwrap_or(rest.len()); - if end == 0 { None } else { Some(&rest[..end]) } -} /// Lifecycle state of a download job. #[derive(Clone, Copy, PartialEq, Eq)] @@ -278,33 +255,51 @@ impl Downloader { /// Spawn a yt-dlp process for `url` and track it as a new [`Job`]. /// - /// The output path template is derived from `kind` so that channels, - /// playlists, and individual videos land in the right sub-directories. + /// Output path template is derived from `info.platform` + `info.kind` so + /// each platform lands in its own sibling directory (`channels/` for + /// YouTube, `tiktok/`, `twitch/`, etc. for others). + /// + /// For channel downloads we also drop a `.source-url` sidecar so future + /// re-checks recover the original URL without folder-name guessing. /// /// When `full_scan` is false (default / incremental mode) `--break-on-existing` /// is passed so yt-dlp stops as soon as it hits the first already-archived /// video — fast for routine channel checks. When `full_scan` is true the /// flag is omitted so every video is checked individually against the /// download archive; slower, but correctly fills gaps in the history. - pub fn start(&mut self, url: String, kind: &UrlKind, full_scan: bool, quality: DownloadQuality) { - let archive_path = self.channels_root.join("archive.txt"); + pub fn start(&mut self, url: String, info: &UrlInfo, full_scan: bool, quality: DownloadQuality) { + let platform_dir = platform::platform_root(&self.channels_root, info.platform); + // Per-platform download archive keeps cross-platform IDs from colliding + // (TikTok IDs are numeric, YouTube IDs are 11-char base64, etc.). + let _ = std::fs::create_dir_all(&platform_dir); + let archive_path = platform_dir.join("archive.txt"); + let platform_label = info.platform.dir_name(); - let (out_arg, label) = match kind { + let (out_arg, label) = match &info.kind { UrlKind::Channel { handle } => { - let dir = self.channels_root.join(handle); + let dir = platform_dir.join(handle); let _ = std::fs::create_dir_all(&dir); + // Remember the originating URL so re-checks don't have to + // guess from the folder name. + platform::write_source_url(&dir, &url); ( format!("{}/%(title)s [%(id)s].%(ext)s", dir.display()), - format!("channels/{}/", handle), + format!("{}/{}/", platform_label, handle), ) } UrlKind::Playlist => ( - format!("{}/%(channel)s/%(playlist_title)s/%(title)s [%(id)s].%(ext)s", self.channels_root.display()), - "channels///".to_string(), + format!( + "{}/%(uploader,channel,creator|Unknown)s/%(playlist_title)s/%(title)s [%(id)s].%(ext)s", + platform_dir.display() + ), + format!("{}///", platform_label), ), UrlKind::Video | UrlKind::Unknown => ( - format!("{}/%(channel)s/%(title)s [%(id)s].%(ext)s", self.channels_root.display()), - "channels//".to_string(), + format!( + "{}/%(uploader,channel,creator|Unknown)s/%(title)s [%(id)s].%(ext)s", + platform_dir.display() + ), + format!("{}//", platform_label), ), }; @@ -573,14 +568,6 @@ mod tests { assert!(parse_progress("").is_none()); } - #[test] - fn extract_after_strips_at_separator() { - assert_eq!(extract_after("https://youtube.com/@handle/videos", "/@"), Some("handle")); - assert_eq!(extract_after("https://youtube.com/@handle", "/@"), Some("handle")); - assert_eq!(extract_after("https://youtube.com/@handle?x=1", "/@"), Some("handle")); - assert_eq!(extract_after("nothing-here", "/@"), None); - } - #[test] fn check_url_for_folder_picks_channel_form_for_ids() { let url = check_url_for_folder("UC1234567890123456789012"); @@ -592,12 +579,5 @@ mod tests { let url = check_url_for_folder("LinusTechTips"); assert_eq!(url, "https://www.youtube.com/@LinusTechTips"); } - - #[test] - fn detect_url_kind_classifies_paths() { - assert!(matches!(detect_url_kind("https://www.youtube.com/playlist?list=PL1"), UrlKind::Playlist)); - assert!(matches!(detect_url_kind("https://www.youtube.com/watch?v=abc"), UrlKind::Video)); - assert!(matches!(detect_url_kind("https://youtu.be/abc"), UrlKind::Video)); - assert!(matches!(detect_url_kind("https://www.youtube.com/@handle"), UrlKind::Channel { .. })); - } + // URL classification tests live in `platform` now — see its tests module. } diff --git a/src/library.rs b/src/library.rs index 33ab792..b6e8da0 100644 --- a/src/library.rs +++ b/src/library.rs @@ -22,6 +22,8 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use crate::platform::{self, Platform}; + const VIDEO_EXTS: &[&str] = &["mkv", "mp4", "webm", "m4v", "mov", "avi"]; const AUDIO_EXTS: &[&str] = &["mp3", "m4a", "opus", "flac", "ogg", "wav", "aac"]; const THUMB_EXTS: &[&str] = &["webp", "jpg", "jpeg", "png"]; @@ -97,6 +99,11 @@ pub struct ChannelMeta { pub struct Channel { pub name: String, pub path: PathBuf, + /// Source platform — drives sidebar grouping and the re-check URL. + pub platform: Platform, + /// Originating URL read from the `.source-url` sidecar. Falls back to a + /// folder-name heuristic for legacy YouTube libraries that predate it. + pub source_url: Option, /// Videos stored directly inside the channel directory (not in a sub-folder). pub videos: Vec