Skip to main content

omni_dev/cli/transcript/youtube/
sync.rs

1//! `omni-dev transcript youtube sync` — enumerate a channel's videos and
2//! sync their transcripts to the filesystem, incrementally.
3//!
4//! Builds on the per-video fetcher: enumerate video IDs in each channel
5//! ([`Youtube::recent_channel_videos`] for incremental, [`Youtube::all_channel_video_ids`]
6//! for `--full` backfill), then loop the existing [`Youtube::fetch`], writing
7//! each transcript to a deterministic path `<out>/<channel-id>/<video-id>.<lang>.<format>`.
8//! "Already synced" is filesystem state: the target path existing means skip.
9//!
10//! Videos without a usable transcript (no captions, age-gated, region-locked)
11//! are recorded and skipped — never abort the whole run.
12
13use std::path::{Path, PathBuf};
14
15use anyhow::{Context, Result};
16use chrono::{DateTime, NaiveDate, Utc};
17use clap::Parser;
18use futures::stream::{self, StreamExt};
19
20use crate::cli::transcript::format::CliFormat;
21use crate::transcript::error::TranscriptError;
22use crate::transcript::format::Format;
23use crate::transcript::source::{FetchOpts, TranscriptSource};
24use crate::transcript::sources::youtube::Youtube;
25
26/// Syncs transcripts for all videos in one or more YouTube channels to the
27/// filesystem, incrementally (skips videos already synced).
28#[derive(Parser)]
29pub struct SyncCommand {
30    /// One or more channel references: a `UC…` ID, a channel URL
31    /// (`/channel/UC…`, `/@handle`, `/c/Name`, `/user/Name`), or a bare
32    /// `@handle`.
33    #[arg(required = true, num_args = 1..)]
34    pub channels: Vec<String>,
35
36    /// Output directory. Transcripts are written under
37    /// `<out>/<channel-id>/<video-id>.<lang>.<format>`.
38    #[arg(long)]
39    pub out: PathBuf,
40
41    /// Preferred caption language (e.g. `en`, `en-US`). Prefix fallback is
42    /// applied — `en` matches `en-US`.
43    #[arg(long, default_value = "en")]
44    pub lang: String,
45
46    /// Output format.
47    #[arg(long, value_enum, default_value_t = CliFormat::Srt)]
48    pub format: CliFormat,
49
50    /// Allow falling through to auto-generated (ASR) captions when no manual
51    /// track matches. Recommended for channel syncs — many uploads only have
52    /// auto captions.
53    #[arg(long)]
54    pub auto: bool,
55
56    /// Backfill the channel's entire upload history via the InnerTube
57    /// `/browse` endpoint. Without this, only the ~15 most recent uploads
58    /// (the RSS feed) are considered.
59    #[arg(long)]
60    pub full: bool,
61
62    /// Only sync videos published on or after this date (`YYYY-MM-DD` or an
63    /// RFC 3339 timestamp). Reliable for the default (RSS) path, which carries
64    /// per-video publish dates; best-effort under `--full` (the browse grid
65    /// has no per-item dates, so this is ignored there).
66    #[arg(long, value_name = "DATE")]
67    pub since: Option<String>,
68
69    /// Maximum number of transcripts to fetch concurrently.
70    #[arg(long, default_value_t = 4)]
71    pub concurrency: usize,
72
73    /// List what would be fetched without downloading or writing anything.
74    #[arg(long)]
75    pub dry_run: bool,
76}
77
78impl SyncCommand {
79    /// Runs the sync against the public YouTube origin.
80    pub async fn execute(self) -> Result<()> {
81        let yt = Youtube::new()?;
82        self.sync_with(&yt).await
83    }
84
85    /// Plan, run, and report the sync against `yt`. Split from [`Self::execute`]
86    /// so the whole orchestration is testable with a mock-backed [`Youtube`];
87    /// `execute` only adds construction of the live client.
88    async fn sync_with(self, yt: &Youtube) -> Result<()> {
89        let plan = self.into_plan()?;
90        let report = run(&plan, yt).await;
91        report.print();
92        Ok(())
93    }
94
95    /// Validate and normalise CLI args into a [`SyncPlan`].
96    fn into_plan(self) -> Result<SyncPlan> {
97        let since = self
98            .since
99            .as_deref()
100            .map(parse_since)
101            .transpose()
102            .context("Invalid --since date")?;
103        Ok(SyncPlan {
104            channels: self.channels,
105            out: self.out,
106            lang: self.lang,
107            format: self.format,
108            allow_auto: self.auto,
109            full: self.full,
110            since,
111            concurrency: self.concurrency.max(1),
112            dry_run: self.dry_run,
113        })
114    }
115}
116
117/// Parse a `--since` value as either a bare `YYYY-MM-DD` date (interpreted as
118/// midnight UTC) or a full RFC 3339 timestamp.
119fn parse_since(s: &str) -> Result<DateTime<Utc>> {
120    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
121        return Ok(dt.with_timezone(&Utc));
122    }
123    let date = NaiveDate::parse_from_str(s, "%Y-%m-%d")
124        .with_context(|| format!("expected YYYY-MM-DD or RFC 3339, got `{s}`"))?;
125    #[allow(clippy::expect_used)]
126    let naive = date
127        .and_hms_opt(0, 0, 0)
128        .expect("midnight is always a valid time");
129    Ok(DateTime::from_naive_utc_and_offset(naive, Utc))
130}
131
132/// Validated, normalised sync parameters (independent of the HTTP source so
133/// the core loop is testable).
134struct SyncPlan {
135    channels: Vec<String>,
136    out: PathBuf,
137    lang: String,
138    format: CliFormat,
139    allow_auto: bool,
140    full: bool,
141    since: Option<DateTime<Utc>>,
142    concurrency: usize,
143    dry_run: bool,
144}
145
146/// Tally of a sync run, accumulated across all channels.
147#[derive(Debug, Default, PartialEq, Eq)]
148struct SyncReport {
149    /// Transcripts newly written.
150    synced: usize,
151    /// Skipped because the target file already existed.
152    already_present: usize,
153    /// Skipped because the video has no usable transcript (no captions,
154    /// age-gated, region-locked, …).
155    no_transcript: usize,
156    /// Failed for another reason (HTTP, parse, I/O).
157    failed: usize,
158    /// `--dry-run`: would have been fetched.
159    would_fetch: usize,
160    /// Channels that could not be resolved or enumerated.
161    channel_errors: usize,
162}
163
164impl SyncReport {
165    fn print(&self) {
166        println!("\nSync complete:");
167        if self.would_fetch > 0 {
168            println!("  would fetch:      {}", self.would_fetch);
169        }
170        println!("  synced:           {}", self.synced);
171        println!("  already present:  {}", self.already_present);
172        println!("  no transcript:    {}", self.no_transcript);
173        println!("  failed:           {}", self.failed);
174        if self.channel_errors > 0 {
175            println!("  channel errors:   {}", self.channel_errors);
176        }
177    }
178}
179
180/// Core sync loop. Resolves and enumerates each channel, plans which videos
181/// need fetching (filesystem-as-state), then fetches the missing ones
182/// concurrently. Never aborts on a single video or channel failure.
183async fn run(plan: &SyncPlan, yt: &Youtube) -> SyncReport {
184    let mut report = SyncReport::default();
185    for channel in &plan.channels {
186        if let Err(e) = sync_channel(plan, yt, channel, &mut report).await {
187            eprintln!("channel `{channel}`: {e}");
188            report.channel_errors += 1;
189        }
190    }
191    report
192}
193
194/// Resolve, enumerate, and sync a single channel into `report`. Returns `Err`
195/// only for channel-level failures (resolution / enumeration); per-video
196/// failures are folded into `report`.
197async fn sync_channel(
198    plan: &SyncPlan,
199    yt: &Youtube,
200    channel: &str,
201    report: &mut SyncReport,
202) -> Result<(), TranscriptError> {
203    let channel_id = yt.resolve_channel_id(channel).await?;
204    let dir = plan.out.join(&channel_id);
205    if !plan.dry_run {
206        std::fs::create_dir_all(&dir)?;
207    }
208
209    let ids = enumerate(plan, yt, &channel_id).await?;
210    let ext = Format::from(plan.format).as_str();
211    let plan_result = plan_fetches(&ids, &dir, &plan.lang, ext, plan.full);
212    report.already_present += plan_result.already_present;
213
214    println!(
215        "channel {channel_id}: {} video(s), {} to fetch, {} already present",
216        ids.len(),
217        plan_result.to_fetch.len(),
218        plan_result.already_present,
219    );
220
221    if plan.dry_run {
222        for (id, path) in &plan_result.to_fetch {
223            println!("  would fetch {id} -> {}", path.display());
224        }
225        report.would_fetch += plan_result.to_fetch.len();
226        return Ok(());
227    }
228
229    let opts = FetchOpts {
230        language: plan.lang.clone(),
231        allow_auto: plan.allow_auto,
232        translate_to: None,
233    };
234
235    let outcomes = stream::iter(plan_result.to_fetch)
236        .map(|(id, path)| {
237            let opts = &opts;
238            async move {
239                let outcome = fetch_and_write(yt, &id, opts, plan.format, &path).await;
240                (id, outcome)
241            }
242        })
243        .buffer_unordered(plan.concurrency)
244        .collect::<Vec<_>>()
245        .await;
246
247    for (id, outcome) in outcomes {
248        match outcome {
249            Ok(()) => report.synced += 1,
250            Err(e) if is_no_transcript(&e) => {
251                report.no_transcript += 1;
252                eprintln!("  skip {id}: {e}");
253            }
254            Err(e) => {
255                report.failed += 1;
256                eprintln!("  fail {id}: {e}");
257            }
258        }
259    }
260    Ok(())
261}
262
263/// Enumerate a channel's video IDs, newest-first. `--full` pages the whole
264/// upload history via browse; otherwise the RSS feed, filtered by `--since`.
265async fn enumerate(
266    plan: &SyncPlan,
267    yt: &Youtube,
268    channel_id: &str,
269) -> Result<Vec<String>, TranscriptError> {
270    if plan.full {
271        yt.all_channel_video_ids(channel_id).await
272    } else {
273        let entries = yt.recent_channel_videos(channel_id).await?;
274        Ok(entries
275            .into_iter()
276            .filter(|e| match (plan.since, e.published) {
277                (Some(since), Some(published)) => published >= since,
278                // No lower bound, or no date to compare against → keep it.
279                _ => true,
280            })
281            .map(|e| e.id)
282            .collect())
283    }
284}
285
286/// Result of partitioning enumerated IDs against on-disk state.
287struct FetchPlan {
288    to_fetch: Vec<(String, PathBuf)>,
289    already_present: usize,
290}
291
292/// Decide which enumerated videos need fetching. IDs are newest-first, so in
293/// incremental mode (`full == false`) scanning stops at the first
294/// already-present file: older uploads are assumed already synced, which
295/// makes routine re-syncs cheap. Under `--full`, every ID is examined so gaps
296/// in the history get filled.
297fn plan_fetches(ids: &[String], dir: &Path, lang: &str, ext: &str, full: bool) -> FetchPlan {
298    let mut to_fetch = Vec::new();
299    let mut already_present = 0;
300    for id in ids {
301        let path = dir.join(format!("{id}.{lang}.{ext}"));
302        if path.exists() {
303            already_present += 1;
304            if !full {
305                break;
306            }
307        } else {
308            to_fetch.push((id.clone(), path));
309        }
310    }
311    FetchPlan {
312        to_fetch,
313        already_present,
314    }
315}
316
317/// Fetch one transcript and write it atomically (temp file + rename) so a
318/// partially written file is never mistaken for a completed sync.
319async fn fetch_and_write(
320    yt: &Youtube,
321    id: &str,
322    opts: &FetchOpts,
323    format: CliFormat,
324    path: &Path,
325) -> Result<(), TranscriptError> {
326    let transcript = yt.fetch(id, opts).await?;
327    let rendered = Format::from(format).render(&transcript)?;
328    write_atomic(path, &rendered)?;
329    Ok(())
330}
331
332/// Write `contents` to `path` via a sibling temp file then rename, so readers
333/// (including the next sync) never observe a half-written transcript.
334fn write_atomic(path: &Path, contents: &str) -> std::io::Result<()> {
335    let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("out");
336    let tmp = path.with_file_name(format!(".{file_name}.tmp"));
337    std::fs::write(&tmp, contents)?;
338    std::fs::rename(&tmp, path)
339}
340
341/// Whether `err` means "this video simply has no transcript we can use" — a
342/// skip-and-record condition rather than a run-aborting failure.
343fn is_no_transcript(err: &TranscriptError) -> bool {
344    matches!(
345        err,
346        TranscriptError::PlayabilityRefused { .. }
347            | TranscriptError::LanguageNotFound { .. }
348            | TranscriptError::AutoCaptionsRequireOptIn(_)
349    )
350}
351
352#[cfg(test)]
353#[allow(clippy::unwrap_used, clippy::expect_used)]
354mod tests {
355    use super::*;
356    use clap::{CommandFactory, FromArgMatches};
357
358    fn parse(args: &[&str]) -> SyncCommand {
359        let cmd = SyncCommand::command().no_binary_name(true);
360        let matches = cmd.try_get_matches_from(args).unwrap();
361        SyncCommand::from_arg_matches(&matches).unwrap()
362    }
363
364    #[test]
365    fn sync_command_defaults() {
366        let cmd = parse(&["@handle", "--out", "/tmp/yt"]);
367        assert_eq!(cmd.channels, vec!["@handle".to_string()]);
368        assert_eq!(cmd.out, PathBuf::from("/tmp/yt"));
369        assert_eq!(cmd.lang, "en");
370        assert_eq!(cmd.format, CliFormat::Srt);
371        assert!(!cmd.auto);
372        assert!(!cmd.full);
373        assert_eq!(cmd.since, None);
374        assert_eq!(cmd.concurrency, 4);
375        assert!(!cmd.dry_run);
376    }
377
378    #[test]
379    fn sync_command_all_flags_and_multiple_channels() {
380        let cmd = parse(&[
381            "UC_x5XG1OV2P6uZZ5FSM9Ttw",
382            "@another",
383            "--out",
384            "/tmp/out",
385            "--lang",
386            "fr",
387            "--format",
388            "vtt",
389            "--auto",
390            "--full",
391            "--since",
392            "2024-01-01",
393            "--concurrency",
394            "8",
395            "--dry-run",
396        ]);
397        assert_eq!(cmd.channels.len(), 2);
398        assert_eq!(cmd.lang, "fr");
399        assert_eq!(cmd.format, CliFormat::Vtt);
400        assert!(cmd.auto);
401        assert!(cmd.full);
402        assert_eq!(cmd.since.as_deref(), Some("2024-01-01"));
403        assert_eq!(cmd.concurrency, 8);
404        assert!(cmd.dry_run);
405    }
406
407    #[test]
408    fn sync_command_requires_a_channel() {
409        let cmd = SyncCommand::command().no_binary_name(true);
410        assert!(cmd.try_get_matches_from(["--out", "/tmp"]).is_err());
411    }
412
413    #[test]
414    fn parse_since_accepts_date_and_rfc3339() {
415        let date = parse_since("2024-11-20").unwrap();
416        assert_eq!(date.to_rfc3339(), "2024-11-20T00:00:00+00:00");
417        let ts = parse_since("2024-11-20T12:30:00+00:00").unwrap();
418        assert_eq!(ts.to_rfc3339(), "2024-11-20T12:30:00+00:00");
419    }
420
421    #[test]
422    fn parse_since_rejects_garbage() {
423        assert!(parse_since("not-a-date").is_err());
424    }
425
426    #[test]
427    fn into_plan_clamps_zero_concurrency_to_one() {
428        let cmd = parse(&["@h", "--out", "/tmp", "--concurrency", "0"]);
429        let plan = cmd.into_plan().unwrap();
430        assert_eq!(plan.concurrency, 1);
431    }
432
433    #[test]
434    fn plan_fetches_incremental_stops_at_first_present() {
435        let dir = tempfile::tempdir().unwrap();
436        // Newest-first ids; mark the 2nd as already present.
437        let ids: Vec<String> = vec!["aaa".into(), "bbb".into(), "ccc".into()];
438        std::fs::write(dir.path().join("bbb.en.srt"), "x").unwrap();
439
440        let plan = plan_fetches(ids.as_slice(), dir.path(), "en", "srt", false);
441        // aaa is fetched; bbb is present → stop before ccc.
442        assert_eq!(plan.already_present, 1);
443        assert_eq!(
444            plan.to_fetch
445                .iter()
446                .map(|(id, _)| id.clone())
447                .collect::<Vec<_>>(),
448            vec!["aaa".to_string()]
449        );
450    }
451
452    #[test]
453    fn plan_fetches_full_fills_gaps() {
454        let dir = tempfile::tempdir().unwrap();
455        let ids: Vec<String> = vec!["aaa".into(), "bbb".into(), "ccc".into()];
456        std::fs::write(dir.path().join("bbb.en.srt"), "x").unwrap();
457
458        let plan = plan_fetches(ids.as_slice(), dir.path(), "en", "srt", true);
459        // --full examines all: bbb present, aaa+ccc to fetch.
460        assert_eq!(plan.already_present, 1);
461        assert_eq!(
462            plan.to_fetch
463                .iter()
464                .map(|(id, _)| id.clone())
465                .collect::<Vec<_>>(),
466            vec!["aaa".to_string(), "ccc".to_string()]
467        );
468    }
469
470    #[test]
471    fn plan_fetches_uses_lang_and_ext_in_path() {
472        let dir = tempfile::tempdir().unwrap();
473        let ids = vec!["zzz".to_string()];
474        let plan = plan_fetches(&ids, dir.path(), "fr", "vtt", false);
475        let (_, path) = &plan.to_fetch[0];
476        assert!(path.ends_with("zzz.fr.vtt"));
477    }
478
479    #[test]
480    fn write_atomic_writes_and_leaves_no_temp() {
481        let dir = tempfile::tempdir().unwrap();
482        let path = dir.path().join("vid.en.srt");
483        write_atomic(&path, "hello").unwrap();
484        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello");
485        assert!(!dir.path().join(".vid.en.srt.tmp").exists());
486    }
487
488    #[test]
489    fn is_no_transcript_classifies_skip_conditions() {
490        assert!(is_no_transcript(&TranscriptError::PlayabilityRefused {
491            status: "LOGIN_REQUIRED".into(),
492            reason: None,
493        }));
494        assert!(is_no_transcript(&TranscriptError::LanguageNotFound {
495            requested: "en".into(),
496            available: vec![],
497        }));
498        assert!(is_no_transcript(
499            &TranscriptError::AutoCaptionsRequireOptIn("en".into())
500        ));
501        assert!(!is_no_transcript(&TranscriptError::ParseError("x".into())));
502    }
503
504    // ── wiremock-driven end-to-end sync ──
505
506    use serde_json::Value;
507    use wiremock::matchers::{body_partial_json, method, path};
508    use wiremock::{Mock, MockServer, ResponseTemplate};
509
510    const CHANNEL_ID: &str = "UC_x5XG1OV2P6uZZ5FSM9Ttw";
511    const RSS_FEED: &str =
512        include_str!("../../../transcript/sources/youtube/fixtures/channel_rss.xml");
513    const PLAYER_RESPONSE: &str =
514        include_str!("../../../transcript/sources/youtube/fixtures/player_response_basic.json");
515    const PLAYER_RESPONSE_AGE_GATED: &str =
516        include_str!("../../../transcript/sources/youtube/fixtures/player_response_age_gated.json");
517    const TIMEDTEXT: &str =
518        include_str!("../../../transcript/sources/youtube/fixtures/timedtext_basic.json");
519    const WATCH_PAGE: &str = include_str!(
520        "../../../transcript/sources/youtube/fixtures/watch_page_with_visitor_data.html"
521    );
522    const BROWSE_PAGE1: &str =
523        include_str!("../../../transcript/sources/youtube/fixtures/browse_videos_page1.json");
524    const BROWSE_PAGE2: &str =
525        include_str!("../../../transcript/sources/youtube/fixtures/browse_videos_page2.json");
526
527    /// Point every caption track's `baseUrl` at the mock so `select_track`
528    /// yields a URL the same server answers (mirrors the youtube.rs test
529    /// helper).
530    fn player_response_for(mock_uri: &str) -> String {
531        let mut value: Value = serde_json::from_str(PLAYER_RESPONSE).unwrap();
532        let tracks = value["captions"]["playerCaptionsTracklistRenderer"]["captionTracks"]
533            .as_array_mut()
534            .unwrap();
535        for track in tracks {
536            let lang = track["languageCode"].as_str().unwrap().to_string();
537            track["baseUrl"] = Value::String(format!("{mock_uri}/api/timedtext?lang={lang}"));
538        }
539        serde_json::to_string(&value).unwrap()
540    }
541
542    async fn mount_rss(server: &MockServer) {
543        Mock::given(method("GET"))
544            .and(path("/feeds/videos.xml"))
545            .respond_with(ResponseTemplate::new(200).set_body_string(RSS_FEED))
546            .mount(server)
547            .await;
548    }
549
550    async fn mount_watch(server: &MockServer) {
551        Mock::given(method("GET"))
552            .and(path("/watch"))
553            .respond_with(ResponseTemplate::new(200).set_body_string(WATCH_PAGE))
554            .mount(server)
555            .await;
556    }
557
558    /// Mount a `/player` mock returning `body` for every video, plus the
559    /// timedtext endpoint its caption URLs point at.
560    async fn mount_player_body(server: &MockServer, body: String) {
561        Mock::given(method("POST"))
562            .and(path("/youtubei/v1/player"))
563            .respond_with(ResponseTemplate::new(200).set_body_string(body))
564            .mount(server)
565            .await;
566        Mock::given(method("GET"))
567            .and(path("/api/timedtext"))
568            .respond_with(ResponseTemplate::new(200).set_body_string(TIMEDTEXT))
569            .mount(server)
570            .await;
571    }
572
573    /// Mount the two-page `/browse` continuation sequence.
574    async fn mount_browse(server: &MockServer) {
575        Mock::given(method("POST"))
576            .and(path("/youtubei/v1/browse"))
577            .and(body_partial_json(
578                serde_json::json!({ "browseId": CHANNEL_ID }),
579            ))
580            .respond_with(ResponseTemplate::new(200).set_body_string(BROWSE_PAGE1))
581            .mount(server)
582            .await;
583        Mock::given(method("POST"))
584            .and(path("/youtubei/v1/browse"))
585            .and(body_partial_json(
586                serde_json::json!({ "continuation": "CONT_TOKEN_1" }),
587            ))
588            .respond_with(ResponseTemplate::new(200).set_body_string(BROWSE_PAGE2))
589            .mount(server)
590            .await;
591    }
592
593    /// Full happy-path server: RSS + watch + basic player + timedtext.
594    async fn mock_youtube() -> (MockServer, Youtube) {
595        let server = MockServer::start().await;
596        mount_rss(&server).await;
597        mount_watch(&server).await;
598        mount_player_body(&server, player_response_for(&server.uri())).await;
599        let yt = Youtube::with_base_url(server.uri()).unwrap();
600        (server, yt)
601    }
602
603    fn plan_for(out: PathBuf) -> SyncPlan {
604        SyncPlan {
605            channels: vec![CHANNEL_ID.to_string()],
606            out,
607            lang: "en".to_string(),
608            format: CliFormat::Srt,
609            allow_auto: false,
610            full: false,
611            since: None,
612            concurrency: 2,
613            dry_run: false,
614        }
615    }
616
617    #[tokio::test]
618    async fn run_writes_transcripts_then_skips_on_resync() {
619        let (_server, yt) = mock_youtube().await;
620        let dir = tempfile::tempdir().unwrap();
621        let plan = plan_for(dir.path().to_path_buf());
622
623        // First run: all 3 RSS entries are new.
624        let report = run(&plan, &yt).await;
625        assert_eq!(report.synced, 3);
626        assert_eq!(report.already_present, 0);
627        assert_eq!(report.failed, 0);
628
629        let channel_dir = dir.path().join(CHANNEL_ID);
630        for id in ["aaaaaaaaaaa", "bbbbbbbbbbb", "ccccccccccc"] {
631            assert!(channel_dir.join(format!("{id}.en.srt")).exists());
632        }
633
634        // Second run: newest is already present → incremental early-stop.
635        let report2 = run(&plan, &yt).await;
636        assert_eq!(report2.synced, 0);
637        assert_eq!(report2.already_present, 1);
638    }
639
640    #[tokio::test]
641    async fn run_dry_run_writes_nothing() {
642        let (_server, yt) = mock_youtube().await;
643        let dir = tempfile::tempdir().unwrap();
644        let mut plan = plan_for(dir.path().to_path_buf());
645        plan.dry_run = true;
646
647        let report = run(&plan, &yt).await;
648        assert_eq!(report.would_fetch, 3);
649        assert_eq!(report.synced, 0);
650        // No channel directory is created in dry-run.
651        assert!(!dir.path().join(CHANNEL_ID).exists());
652    }
653
654    #[tokio::test]
655    async fn run_full_enumerates_via_browse() {
656        // --full takes the browse path (not RSS), paging both fixture pages.
657        let server = MockServer::start().await;
658        mount_watch(&server).await;
659        mount_browse(&server).await;
660        mount_player_body(&server, player_response_for(&server.uri())).await;
661        let yt = Youtube::with_base_url(server.uri()).unwrap();
662
663        let dir = tempfile::tempdir().unwrap();
664        let mut plan = plan_for(dir.path().to_path_buf());
665        plan.full = true;
666
667        let report = run(&plan, &yt).await;
668        assert_eq!(report.synced, 3);
669        let channel_dir = dir.path().join(CHANNEL_ID);
670        for id in ["vid00000001", "vid00000002", "vid00000003"] {
671            assert!(channel_dir.join(format!("{id}.en.srt")).exists());
672        }
673    }
674
675    #[tokio::test]
676    async fn run_since_filters_out_older_entries() {
677        // RSS entries are dated 2024-11-24 / -20 / -15; a 2024-11-21 lower
678        // bound keeps only the newest.
679        let (_server, yt) = mock_youtube().await;
680        let dir = tempfile::tempdir().unwrap();
681        let mut plan = plan_for(dir.path().to_path_buf());
682        plan.since = Some(parse_since("2024-11-21").unwrap());
683
684        let report = run(&plan, &yt).await;
685        assert_eq!(report.synced, 1);
686        assert!(dir
687            .path()
688            .join(CHANNEL_ID)
689            .join("aaaaaaaaaaa.en.srt")
690            .exists());
691    }
692
693    #[tokio::test]
694    async fn run_records_no_transcript_for_age_gated() {
695        // Age-gated player response → PlayabilityRefused → skip-and-record.
696        let server = MockServer::start().await;
697        mount_rss(&server).await;
698        mount_watch(&server).await;
699        mount_player_body(&server, PLAYER_RESPONSE_AGE_GATED.to_string()).await;
700        let yt = Youtube::with_base_url(server.uri()).unwrap();
701
702        let dir = tempfile::tempdir().unwrap();
703        let report = run(&plan_for(dir.path().to_path_buf()), &yt).await;
704        assert_eq!(report.no_transcript, 3);
705        assert_eq!(report.synced, 0);
706        assert_eq!(report.failed, 0);
707    }
708
709    #[tokio::test]
710    async fn run_records_failures_on_http_error() {
711        // Player 500 → HTTP error → recorded as a failure, run continues.
712        let server = MockServer::start().await;
713        mount_rss(&server).await;
714        mount_watch(&server).await;
715        Mock::given(method("POST"))
716            .and(path("/youtubei/v1/player"))
717            .respond_with(ResponseTemplate::new(500))
718            .mount(&server)
719            .await;
720        let yt = Youtube::with_base_url(server.uri()).unwrap();
721
722        let dir = tempfile::tempdir().unwrap();
723        let report = run(&plan_for(dir.path().to_path_buf()), &yt).await;
724        assert_eq!(report.failed, 3);
725        assert_eq!(report.synced, 0);
726    }
727
728    #[tokio::test]
729    async fn run_records_channel_error_when_unresolvable() {
730        // Channel page carries no channelId → ChannelNotFound → run records a
731        // channel-level error and keeps going.
732        let server = MockServer::start().await;
733        Mock::given(method("GET"))
734            .and(path("/@ghost"))
735            .respond_with(ResponseTemplate::new(200).set_body_string("<html>no id</html>"))
736            .mount(&server)
737            .await;
738        let yt = Youtube::with_base_url(server.uri()).unwrap();
739
740        let dir = tempfile::tempdir().unwrap();
741        let mut plan = plan_for(dir.path().to_path_buf());
742        plan.channels = vec!["@ghost".to_string()];
743
744        let report = run(&plan, &yt).await;
745        assert_eq!(report.channel_errors, 1);
746        assert_eq!(report.synced, 0);
747    }
748
749    #[tokio::test]
750    async fn sync_with_drives_full_orchestration() {
751        // Covers the execute() orchestration (into_plan → run → print) against
752        // a mock-backed client, without constructing a live Youtube.
753        let (_server, yt) = mock_youtube().await;
754        let dir = tempfile::tempdir().unwrap();
755        let cmd = SyncCommand {
756            channels: vec![CHANNEL_ID.to_string()],
757            out: dir.path().to_path_buf(),
758            lang: "en".to_string(),
759            format: CliFormat::Srt,
760            auto: false,
761            full: false,
762            since: None,
763            concurrency: 2,
764            dry_run: false,
765        };
766        cmd.sync_with(&yt).await.unwrap();
767        assert!(dir
768            .path()
769            .join(CHANNEL_ID)
770            .join("aaaaaaaaaaa.en.srt")
771            .exists());
772    }
773
774    #[tokio::test]
775    async fn sync_with_surfaces_invalid_since() {
776        // The into_plan() error path inside the orchestration.
777        let (_server, yt) = mock_youtube().await;
778        let dir = tempfile::tempdir().unwrap();
779        let cmd = SyncCommand {
780            channels: vec![CHANNEL_ID.to_string()],
781            out: dir.path().to_path_buf(),
782            lang: "en".to_string(),
783            format: CliFormat::Srt,
784            auto: false,
785            full: false,
786            since: Some("not-a-date".to_string()),
787            concurrency: 2,
788            dry_run: false,
789        };
790        let err = cmd.sync_with(&yt).await.unwrap_err();
791        assert!(err.to_string().contains("Invalid --since date"));
792    }
793
794    #[test]
795    fn report_print_covers_optional_branches() {
796        // Exercises the `would_fetch > 0` and `channel_errors > 0` branches.
797        let report = SyncReport {
798            synced: 1,
799            already_present: 2,
800            no_transcript: 3,
801            failed: 4,
802            would_fetch: 5,
803            channel_errors: 6,
804        };
805        report.print();
806        SyncReport::default().print();
807    }
808}