Add multi-platform support: TikTok, Twitch, Vimeo, Bandcamp, SoundCloud, Odysee
yt-dlp already speaks ~1,800 sites; the changes here teach the rest of the app to route them through platform-specific output folders and remember where each creator was downloaded from. New `platform` module - `Platform` enum: YouTube, TikTok, Twitch, Vimeo, Bandcamp, SoundCloud, Odysee, Other. - `classify_url` returns both the source platform and a `UrlKind` (Channel / Playlist / Video / Unknown) — per-platform path parsing for each supported host. - `platform_root(channels_root, platform)` returns the on-disk folder per platform. YouTube keeps the legacy `channels/` for backward compat; the others live as siblings (`tiktok/`, `twitch/`, …). - `write_source_url` / `read_source_url` persist the originating URL in a `.source-url` sidecar at each creator folder so re-checks no longer rely on the YouTube-only "UC + 22 chars" heuristic. Downloader - `Downloader::start` now takes `&UrlInfo` instead of `&UrlKind` and routes the yt-dlp `-o` template into the right platform folder. - For channel downloads it also writes `.source-url` so future re-checks recover the exact URL. - Output template uses `%(uploader,channel,creator|Unknown)s` so non-YouTube sites that don't expose `channel` still get a sensible creator folder. - Per-platform `archive.txt` keeps cross-platform IDs (TikTok numeric vs YouTube base64) from colliding. - New `recheck_url(&Channel)` prefers `source_url` and falls back to the legacy YouTube heuristic for libraries created before this change. Library scanner - `scan_channels` now walks every platform's folder and tags each `Channel` with its `platform` + `source_url`. Sort key puts the platform first, then name, so the sidebar groups cleanly. - `Channel` gains `platform: Platform` and `source_url: Option<String>` fields, populated from the on-disk sidecar. Web UI - `WebState` gains `library_root` (= parent of channels_root). The `/files/` ServeDir is mounted at library_root so non-YouTube videos resolve at `/files/<platform>/<creator>/<file>`. Existing YouTube URLs still work — they're now `/files/channels/...` instead of `/files/...` and the server-rendered JSON updates accordingly. - Maintenance scan + remove use `library_root` so non-YouTube content is included. - `ChannelInfo` JSON exposes `platform`, `platform_label`, `platform_icon`, and `source_url`. - Sidebar groups channels by platform with each platform's icon. - "Check for new videos" now uses the stored `source_url` (with the YouTube heuristic as a last-resort fallback). - Download dialog preview shows the detected source + destination folder. Desktop UI - App gains a `library_root` field mirroring the web side; maintenance ops scan/remove against it. - Sidebar channel labels are prefixed with the platform icon for non-YouTube channels; tooltip carries the platform name. - Download URL field surfaces both source platform and folder path preview as you type. Plex - Non-YouTube creators get their show folder prefixed with the platform name (e.g., `TikTok - cooluser`) so a YouTube channel and a TikTok account with the same name don't collide. Backward compatibility - Existing YouTube libraries (`channels/<handle>/...`) are untouched. New platform folders are created on first run as siblings. - Channels without a `.source-url` (i.e., everything that pre-dates this commit) fall through to the YouTube heuristic, preserving the existing re-check behavior. Tests - 14 new `platform` tests cover URL classification per platform, channel handle extraction, and `platform_root` layout. The old `detect_url_kind` / `extract_after` tests move into platform.rs since the canonical implementation lives there now. - 41 unit tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4d83999edd
commit
12c32f642b
7 changed files with 699 additions and 133 deletions
84
src/app.rs
84
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<library::Channel>,
|
||||
sidebar_view: SidebarView,
|
||||
selected_video: Option<String>,
|
||||
|
|
@ -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<String> = 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/<channel>/<playlist>/".to_string()),
|
||||
UrlKind::Video => ("Video", "→ channels/<channel>/".to_string()),
|
||||
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);
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue