Live-stream recording, recent-additions feed, per-platform impersonate profiles

Three roadmap items together since they touch overlapping code:

Live-stream recording
- New `live: bool` argument on Downloader::start. When true, attaches
  `--live-from-start --wait-for-video 30`, suppresses
  `--break-on-existing` (every recording is a unique file), and
  prepends a UTC timestamp suffix to the output filename so
  re-recordings of the same broadcaster don't collide.
- The job label gains a "🔴 LIVE" marker so a long-running record is
  obviously different from a VOD pull.
- Both UIs gain a "🔴 Live stream" checkbox in the download panel,
  hidden when Music mode is selected. Works for Twitch *and* YouTube
  Live since yt-dlp recognizes both from the URL.
- Web `POST /api/download` accepts a `live: bool` body field; all
  other callers (scheduled re-check, right-click "Check for new
  videos") pass false.

Recent-additions feed
- `library::Video` gains `mtime_unix: Option<u64>` — populated from
  the metadata we already read for `file_size`, so no extra fs hit.
- New `SidebarView::Recent` (desktop) + `showRecent` (web) sort the
  whole library by mtime descending and cap at 100 entries.
- Sidebar entry "🕒 Recent additions" appears only when the library
  has any dated content, so a fresh install doesn't show an empty view.

Per-platform impersonation
- New `Platform::impersonate_target()`:
  - Twitch → None (skip impersonation; their OAuth/Helix dislikes
    mismatched TLS fingerprints).
  - TikTok → "Chrome-Android-131" (mobile profile matches their
    first-party app surface).
  - Everything else → "Chrome-146:Macos-26".
- `Downloader::apply_impersonation` now takes the platform and routes
  through the override; `repair()` (always YouTube-implicit) and
  `start_music()` (uses classify_url on the URL) wire through too.
- `/api/preview` does the same classification before dispatching to
  yt-dlp.
- 3 new tests confirm Twitch → None, TikTok → mobile, YouTube → desktop.

44 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-25 00:17:59 -07:00
parent c0be4bb533
commit b24ef4be67
5 changed files with 245 additions and 42 deletions

View file

@ -47,6 +47,8 @@ enum SidebarView {
Channel(usize),
Playlist(usize, usize),
ContinueWatching,
/// Activity feed — recent additions across all channels, sorted by mtime.
Recent,
Music,
}
@ -60,6 +62,7 @@ struct Card {
duration_secs: Option<f64>,
file_size: Option<u64>,
upload_date: Option<String>,
mtime_unix: Option<u64>,
watched: bool,
resume_pos: Option<f64>,
}
@ -83,6 +86,8 @@ pub struct App {
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,
textures: HashMap<PathBuf, Option<egui::TextureHandle>>,
thumb_request_tx: Sender<PathBuf>,
thumb_result_rx: Receiver<(PathBuf, Option<egui::ColorImage>)>,
@ -219,6 +224,7 @@ impl App {
dl_full_scan: true,
dl_quality: DownloadQuality::Best,
dl_music_mode: false,
dl_live: false,
textures: HashMap::new(),
thumb_request_tx,
thumb_result_rx,
@ -325,6 +331,7 @@ impl App {
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),
resume_pos,
});
@ -347,6 +354,21 @@ impl App {
});
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::All => {
for ch in &self.library {
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
@ -567,7 +589,8 @@ impl App {
.collect();
for url in urls {
let info = classify_url(&url);
self.downloader.start(url, &info, true, DownloadQuality::Best);
// Scheduled re-check: never treat as live.
self.downloader.start(url, &info, true, DownloadQuality::Best, false);
count += 1;
}
self.status = format!("Scheduled check: started {} channel downloads", count);
@ -732,6 +755,23 @@ impl App {
}
}
// 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;
}
let music_count = self.music_library.len();
if ui
.selectable_label(
@ -836,7 +876,7 @@ impl App {
// Process deferred right-click download action
if let Some((url, ch_name)) = pending_ch_download {
let info = classify_url(&url);
self.downloader.start(url, &info, !self.dl_full_scan, DownloadQuality::Best);
self.downloader.start(url, &info, !self.dl_full_scan, DownloadQuality::Best, false);
self.status = format!("Checking {} for new videos…", ch_name);
}
});
@ -901,6 +941,12 @@ impl App {
});
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.",
);
}
let ready = !self.dl_url.trim().is_empty();
@ -911,7 +957,7 @@ impl App {
self.downloader.start_music(url);
self.status = "Downloading music…".to_string();
} else {
self.downloader.start(url, &info, !self.dl_full_scan, self.dl_quality);
self.downloader.start(url, &info, !self.dl_full_scan, self.dl_quality, self.dl_live);
self.status = format!("Downloading: {dest}");
}
}