1use 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#[derive(Parser)]
29pub struct SyncCommand {
30 #[arg(required = true, num_args = 1..)]
34 pub channels: Vec<String>,
35
36 #[arg(long)]
39 pub out: PathBuf,
40
41 #[arg(long, default_value = "en")]
44 pub lang: String,
45
46 #[arg(long, value_enum, default_value_t = CliFormat::Srt)]
48 pub format: CliFormat,
49
50 #[arg(long)]
54 pub auto: bool,
55
56 #[arg(long)]
60 pub full: bool,
61
62 #[arg(long, value_name = "DATE")]
67 pub since: Option<String>,
68
69 #[arg(long, default_value_t = 4)]
71 pub concurrency: usize,
72
73 #[arg(long)]
75 pub dry_run: bool,
76}
77
78impl SyncCommand {
79 pub async fn execute(self) -> Result<()> {
81 let yt = Youtube::new()?;
82 self.sync_with(&yt).await
83 }
84
85 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 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
117fn 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
132struct 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#[derive(Debug, Default, PartialEq, Eq)]
148struct SyncReport {
149 synced: usize,
151 already_present: usize,
153 no_transcript: usize,
156 failed: usize,
158 would_fetch: usize,
160 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
180async 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
194async 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
263async 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 _ => true,
280 })
281 .map(|e| e.id)
282 .collect())
283 }
284}
285
286struct FetchPlan {
288 to_fetch: Vec<(String, PathBuf)>,
289 already_present: usize,
290}
291
292fn 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
317async 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
332fn 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
341fn 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 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 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 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 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 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 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 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 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 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 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 assert!(!dir.path().join(CHANNEL_ID).exists());
652 }
653
654 #[tokio::test]
655 async fn run_full_enumerates_via_browse() {
656 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 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 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 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 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 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 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 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}