birdclaw 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -5
  3. package/package.json +3 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +376 -13
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +27 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +452 -0
  13. package/src/components/SyncNowButton.tsx +57 -25
  14. package/src/components/ThemeSlider.tsx +55 -50
  15. package/src/components/TimelineCard.tsx +225 -93
  16. package/src/components/TimelineRouteFrame.tsx +22 -8
  17. package/src/components/TweetMediaGrid.tsx +87 -38
  18. package/src/components/TweetRichText.tsx +15 -11
  19. package/src/components/account-selection.ts +64 -0
  20. package/src/components/useTimelineRouteData.ts +23 -7
  21. package/src/lib/account-sync-job.ts +654 -0
  22. package/src/lib/actions-transport.ts +216 -146
  23. package/src/lib/api-client.ts +128 -53
  24. package/src/lib/archive-finder.ts +78 -63
  25. package/src/lib/archive-import.ts +1364 -1300
  26. package/src/lib/authored-live.ts +261 -204
  27. package/src/lib/avatar-cache.ts +159 -44
  28. package/src/lib/backup.ts +1532 -951
  29. package/src/lib/bird-actions.ts +139 -57
  30. package/src/lib/bird-command.ts +101 -28
  31. package/src/lib/bird.ts +549 -194
  32. package/src/lib/blocklist.ts +40 -23
  33. package/src/lib/blocks-write.ts +129 -80
  34. package/src/lib/blocks.ts +165 -97
  35. package/src/lib/bookmark-sync-job.ts +250 -160
  36. package/src/lib/conversation-surface.ts +79 -48
  37. package/src/lib/db.ts +33 -3
  38. package/src/lib/dms-live.ts +720 -66
  39. package/src/lib/effect-runtime.ts +45 -0
  40. package/src/lib/follow-graph.ts +224 -180
  41. package/src/lib/http-effect.ts +222 -0
  42. package/src/lib/inbox.ts +74 -43
  43. package/src/lib/link-index.ts +88 -76
  44. package/src/lib/link-insights.ts +24 -0
  45. package/src/lib/link-preview-metadata.ts +472 -52
  46. package/src/lib/media-fetch.ts +286 -213
  47. package/src/lib/mention-threads-live.ts +352 -288
  48. package/src/lib/mentions-live.ts +390 -342
  49. package/src/lib/moderation-target.ts +102 -65
  50. package/src/lib/moderation-write.ts +77 -18
  51. package/src/lib/mutes-write.ts +129 -80
  52. package/src/lib/mutes.ts +8 -1
  53. package/src/lib/openai.ts +84 -53
  54. package/src/lib/period-digest.ts +953 -0
  55. package/src/lib/profile-affiliation-hydration.ts +93 -54
  56. package/src/lib/profile-hydration.ts +124 -72
  57. package/src/lib/profile-replies.ts +60 -43
  58. package/src/lib/profile-resolver.ts +402 -294
  59. package/src/lib/queries.ts +969 -199
  60. package/src/lib/research.ts +165 -120
  61. package/src/lib/sqlite.ts +1 -0
  62. package/src/lib/timeline-collections-live.ts +204 -167
  63. package/src/lib/timeline-live.ts +60 -39
  64. package/src/lib/tweet-lookup.ts +30 -19
  65. package/src/lib/types.ts +38 -1
  66. package/src/lib/ui.ts +30 -7
  67. package/src/lib/url-expansion.ts +226 -55
  68. package/src/lib/url-safety.ts +220 -0
  69. package/src/lib/web-sync.ts +216 -148
  70. package/src/lib/whois.ts +166 -147
  71. package/src/lib/x-web.ts +102 -71
  72. package/src/lib/xurl.ts +681 -411
  73. package/src/routeTree.gen.ts +42 -0
  74. package/src/routes/__root.tsx +25 -5
  75. package/src/routes/api/action.tsx +127 -78
  76. package/src/routes/api/avatar.tsx +39 -30
  77. package/src/routes/api/blocks.tsx +26 -23
  78. package/src/routes/api/conversation.tsx +25 -14
  79. package/src/routes/api/inbox.tsx +27 -21
  80. package/src/routes/api/link-insights.tsx +31 -25
  81. package/src/routes/api/link-preview.tsx +25 -21
  82. package/src/routes/api/period-digest.tsx +123 -0
  83. package/src/routes/api/profile-hydrate.tsx +31 -28
  84. package/src/routes/api/query.tsx +79 -55
  85. package/src/routes/api/status.tsx +18 -10
  86. package/src/routes/api/sync.tsx +75 -29
  87. package/src/routes/dms.tsx +95 -28
  88. package/src/routes/inbox.tsx +32 -19
  89. package/src/routes/today.tsx +441 -0
@@ -14,8 +14,10 @@ import {
14
14
  } from "node:fs";
15
15
  import { appendFile, copyFile, rename, rm, writeFile } from "node:fs/promises";
16
16
  import path from "node:path";
17
+ import { Effect } from "effect";
17
18
  import { getBirdclawPaths } from "./config";
18
19
  import { getNativeDb } from "./db";
20
+ import { runEffectPromise } from "./effect-runtime";
19
21
 
20
22
  type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
21
23
  type Row = { id: string; media_json: string };
@@ -100,6 +102,24 @@ function defaultSleep(ms: number) {
100
102
  return new Promise<void>((resolve) => setTimeout(resolve, ms));
101
103
  }
102
104
 
105
+ function toMediaError(error: unknown) {
106
+ return error instanceof Error ? error : new Error(String(error));
107
+ }
108
+
109
+ function tryMediaSync<T>(try_: () => T) {
110
+ return Effect.try({
111
+ try: try_,
112
+ catch: toMediaError,
113
+ });
114
+ }
115
+
116
+ function tryMediaPromise<T>(try_: () => Promise<T>) {
117
+ return Effect.tryPromise({
118
+ try: try_,
119
+ catch: toMediaError,
120
+ });
121
+ }
122
+
103
123
  function fileSize(filePath: string) {
104
124
  try {
105
125
  return statSync(filePath).size;
@@ -454,24 +474,26 @@ function archivePathFor(item: Candidate, mediaOriginalsDir: string) {
454
474
  );
455
475
  }
456
476
 
457
- async function reuseFromArchive(
477
+ function reuseFromArchiveEffect(
458
478
  item: Candidate,
459
479
  mediaOriginalsDir: string,
460
480
  maxBytes: number,
461
- ) {
462
- const archivePath = archivePathFor(item, mediaOriginalsDir);
463
- if (!archivePath || !existsSync(archivePath)) return null;
464
- const bytes = fileSize(archivePath);
465
- if (bytes > maxBytes) return fail(item, "max-bytes");
466
- await copyFile(archivePath, item.tmpPath);
467
- await rename(item.tmpPath, item.path);
468
- return {
469
- fetched: 1,
470
- bytes,
471
- rateLimited: false,
472
- kind: item.kind,
473
- reusedFromArchive: true,
474
- } satisfies FetchOneResult;
481
+ ): Effect.Effect<FetchOneResult | null, Error> {
482
+ return Effect.gen(function* () {
483
+ const archivePath = archivePathFor(item, mediaOriginalsDir);
484
+ if (!archivePath || !existsSync(archivePath)) return null;
485
+ const bytes = fileSize(archivePath);
486
+ if (bytes > maxBytes) return fail(item, "max-bytes");
487
+ yield* tryMediaPromise(() => copyFile(archivePath, item.tmpPath));
488
+ yield* tryMediaPromise(() => rename(item.tmpPath, item.path));
489
+ return {
490
+ fetched: 1,
491
+ bytes,
492
+ rateLimited: false,
493
+ kind: item.kind,
494
+ reusedFromArchive: true,
495
+ } satisfies FetchOneResult;
496
+ });
475
497
  }
476
498
 
477
499
  function contentLength(response: Response) {
@@ -486,49 +508,61 @@ function contentRangeTotal(response: Response) {
486
508
  return total ? Number(total) : null;
487
509
  }
488
510
 
489
- async function writeResponseBody(
511
+ function writeResponseBodyEffect(
490
512
  response: Response,
491
513
  tmpPath: string,
492
514
  append: boolean,
493
515
  maxBytes: number,
494
516
  initialBytes: number,
495
- ) {
496
- if (!response.body) throw new Error("missing response body");
497
- let bytes = 0;
498
- if (!append) {
499
- await writeFile(tmpPath, Buffer.alloc(0));
500
- }
501
- const reader = response.body.getReader();
502
- try {
503
- for (;;) {
504
- const { done, value } = await reader.read();
505
- if (done) {
506
- break;
507
- }
508
- const chunk = Buffer.from(value);
509
- bytes += chunk.length;
510
- if (initialBytes + bytes > maxBytes) {
511
- throw new Error("max-bytes");
512
- }
513
- await appendFile(tmpPath, chunk);
514
- }
515
- } catch (error) {
516
- if (error instanceof Error && error.message === "max-bytes") {
517
- await rm(tmpPath, { force: true });
518
- }
519
- try {
520
- await reader.cancel(error);
521
- } catch {
522
- // The stream may already be errored; preserving the original failure matters.
517
+ ): Effect.Effect<number, Error> {
518
+ return Effect.gen(function* () {
519
+ if (!response.body)
520
+ return yield* Effect.fail(new Error("missing response body"));
521
+ let bytes = 0;
522
+ if (!append) {
523
+ yield* tryMediaPromise(() => writeFile(tmpPath, Buffer.alloc(0)));
523
524
  }
524
- throw error;
525
- } finally {
526
- reader.releaseLock();
527
- }
528
- return bytes;
525
+ const reader = response.body.getReader();
526
+ const result = yield* Effect.gen(function* () {
527
+ for (;;) {
528
+ const { done, value } = yield* tryMediaPromise(() => reader.read());
529
+ if (done) {
530
+ break;
531
+ }
532
+ const chunk = Buffer.from(value);
533
+ bytes += chunk.length;
534
+ if (initialBytes + bytes > maxBytes) {
535
+ yield* tryMediaPromise(() => rm(tmpPath, { force: true })).pipe(
536
+ Effect.catchAll(() => Effect.void),
537
+ );
538
+ return yield* Effect.fail(new Error("max-bytes"));
539
+ }
540
+ yield* tryMediaPromise(() => appendFile(tmpPath, chunk));
541
+ }
542
+ return bytes;
543
+ }).pipe(
544
+ Effect.map((writtenBytes) => ({ ok: true as const, writtenBytes })),
545
+ Effect.catchAll((error) =>
546
+ Effect.gen(function* () {
547
+ if (error.message === "max-bytes") {
548
+ yield* tryMediaPromise(() => rm(tmpPath, { force: true })).pipe(
549
+ Effect.catchAll(() => Effect.void),
550
+ );
551
+ }
552
+ yield* tryMediaPromise(() => reader.cancel(error)).pipe(
553
+ Effect.catchAll(() => Effect.void),
554
+ );
555
+ return { error, ok: false as const };
556
+ }),
557
+ ),
558
+ Effect.ensuring(Effect.sync(() => reader.releaseLock())),
559
+ );
560
+ if (!result.ok) return yield* Effect.fail(result.error);
561
+ return result.writtenBytes;
562
+ });
529
563
  }
530
564
 
531
- async function fetchOne({
565
+ function fetchOneEffect({
532
566
  item,
533
567
  fetchImpl,
534
568
  sleep,
@@ -542,74 +576,85 @@ async function fetchOne({
542
576
  retryMax: number;
543
577
  userAgent: string;
544
578
  maxBytes: number;
545
- }): Promise<FetchOneResult> {
546
- let rateLimited = false;
547
- for (let attempt = 0; attempt <= retryMax; attempt += 1) {
548
- const partialBytes = item.kind === "image" ? 0 : fileSize(item.tmpPath);
549
- if (partialBytes > maxBytes) {
550
- await rm(item.tmpPath, { force: true });
551
- return fail(item, "max-bytes");
552
- }
553
- let response: Response;
554
- try {
555
- response = await fetchImpl(item.url, {
556
- headers: {
557
- "user-agent": userAgent,
558
- ...(partialBytes > 0 ? { range: `bytes=${partialBytes}-` } : {}),
559
- },
560
- });
561
- } catch (error) {
562
- return fail(
563
- item,
564
- error instanceof Error ? error.message : String(error),
565
- rateLimited,
579
+ }): Effect.Effect<FetchOneResult, Error> {
580
+ return Effect.gen(function* () {
581
+ let rateLimited = false;
582
+ for (let attempt = 0; attempt <= retryMax; attempt += 1) {
583
+ const partialBytes = item.kind === "image" ? 0 : fileSize(item.tmpPath);
584
+ if (partialBytes > maxBytes) {
585
+ yield* tryMediaPromise(() => rm(item.tmpPath, { force: true })).pipe(
586
+ Effect.catchAll(() => Effect.void),
587
+ );
588
+ return fail(item, "max-bytes");
589
+ }
590
+ const responseResult = yield* tryMediaPromise(() =>
591
+ fetchImpl(item.url, {
592
+ headers: {
593
+ "user-agent": userAgent,
594
+ ...(partialBytes > 0 ? { range: `bytes=${partialBytes}-` } : {}),
595
+ },
596
+ }),
597
+ ).pipe(
598
+ Effect.map((response) => ({ ok: true as const, response })),
599
+ Effect.catchAll((error) =>
600
+ Effect.succeed({ error, ok: false as const }),
601
+ ),
566
602
  );
567
- }
568
- if (response.status === 429) {
569
- rateLimited = true;
570
- if (attempt < retryMax) {
571
- await sleep(1000 * 2 ** attempt);
572
- continue;
603
+ if (!responseResult.ok) {
604
+ return fail(item, responseResult.error.message, rateLimited);
605
+ }
606
+ const { response } = responseResult;
607
+ if (response.status === 429) {
608
+ rateLimited = true;
609
+ if (attempt < retryMax) {
610
+ yield* tryMediaPromise(() => sleep(1000 * 2 ** attempt)).pipe(
611
+ Effect.catchAll(() => Effect.void),
612
+ );
613
+ continue;
614
+ }
615
+ return fail(item, "429", true);
616
+ }
617
+ if (!response.ok && response.status !== 206) {
618
+ return fail(item, String(response.status), rateLimited);
573
619
  }
574
- return fail(item, "429", true);
575
- }
576
- if (!response.ok && response.status !== 206) {
577
- return fail(item, String(response.status), rateLimited);
578
- }
579
620
 
580
- const expectedTotal =
581
- contentRangeTotal(response) ??
582
- (contentLength(response) ?? 0) +
583
- (response.status === 206 ? partialBytes : 0);
584
- if (expectedTotal > maxBytes) {
585
- await rm(item.tmpPath, { force: true });
586
- return fail(item, "max-bytes");
587
- }
621
+ const expectedTotal =
622
+ contentRangeTotal(response) ??
623
+ (contentLength(response) ?? 0) +
624
+ (response.status === 206 ? partialBytes : 0);
625
+ if (expectedTotal > maxBytes) {
626
+ yield* tryMediaPromise(() => rm(item.tmpPath, { force: true })).pipe(
627
+ Effect.catchAll(() => Effect.void),
628
+ );
629
+ return fail(item, "max-bytes");
630
+ }
588
631
 
589
- const append = partialBytes > 0 && response.status === 206;
590
- let bytes = 0;
591
- try {
592
- bytes = await writeResponseBody(
632
+ const append = partialBytes > 0 && response.status === 206;
633
+ const bytesResult = yield* writeResponseBodyEffect(
593
634
  response,
594
635
  item.tmpPath,
595
636
  append,
596
637
  maxBytes,
597
638
  append ? partialBytes : 0,
639
+ ).pipe(
640
+ Effect.map((bytes) => ({ bytes, ok: true as const })),
641
+ Effect.catchAll((error) =>
642
+ Effect.succeed({ error, ok: false as const }),
643
+ ),
598
644
  );
599
- } catch (error) {
600
- if (error instanceof Error && error.message === "max-bytes") {
601
- return fail(item, "max-bytes", rateLimited);
645
+ if (!bytesResult.ok) {
646
+ return fail(item, bytesResult.error.message, rateLimited);
602
647
  }
603
- return fail(
604
- item,
605
- error instanceof Error ? error.message : String(error),
648
+ yield* tryMediaPromise(() => rename(item.tmpPath, item.path));
649
+ return {
650
+ fetched: 1,
651
+ bytes: bytesResult.bytes,
606
652
  rateLimited,
607
- );
653
+ kind: item.kind,
654
+ };
608
655
  }
609
- await rename(item.tmpPath, item.path);
610
- return { fetched: 1, bytes, rateLimited, kind: item.kind };
611
- }
612
- return fail(item, "retry exhausted", rateLimited);
656
+ return fail(item, "retry exhausted", rateLimited);
657
+ });
613
658
  }
614
659
 
615
660
  function applyFetched(result: MediaFetchResult, fetched: FetchOneResult) {
@@ -632,7 +677,7 @@ function applyFetched(result: MediaFetchResult, fetched: FetchOneResult) {
632
677
  if (fetched.failure) result.failures.push(fetched.failure);
633
678
  }
634
679
 
635
- async function runGroup(
680
+ function runGroupEffect(
636
681
  items: Candidate[],
637
682
  parallel: number,
638
683
  pacingMs: number,
@@ -640,127 +685,155 @@ async function runGroup(
640
685
  sleep: (ms: number) => Promise<void>,
641
686
  worker: (item: Candidate) => Promise<void>,
642
687
  ) {
643
- let next = 0;
644
688
  let lastStart: number | null = null;
645
689
  let pace = Promise.resolve();
646
- const runPaced = async (item: Candidate) => {
690
+ const pacedItems = items.map((item) => {
647
691
  const previous = pace;
648
692
  let release = () => {};
649
693
  pace = new Promise<void>((resolve) => {
650
694
  release = resolve;
651
695
  });
652
- await previous;
653
- let work: Promise<void>;
654
- try {
655
- const waitMs =
656
- lastStart !== null ? Math.max(0, lastStart + pacingMs - now()) : 0;
657
- if (waitMs > 0) await sleep(waitMs);
658
- lastStart = now();
659
- work = worker(item);
660
- } finally {
661
- release();
662
- }
663
- await work;
664
- };
665
- await Promise.all(
666
- Array.from({ length: Math.min(parallel, items.length) }, async () => {
667
- for (;;) {
668
- const item = items[next++];
669
- if (!item) return;
670
- await runPaced(item);
671
- }
672
- }),
673
- );
696
+ return { item, previous, release };
697
+ });
698
+ const runPaced = ({
699
+ item,
700
+ previous,
701
+ release,
702
+ }: {
703
+ item: Candidate;
704
+ previous: Promise<void>;
705
+ release: () => void;
706
+ }) =>
707
+ tryMediaPromise(() =>
708
+ previous.then(() => {
709
+ const waitMs =
710
+ lastStart !== null ? Math.max(0, lastStart + pacingMs - now()) : 0;
711
+ const wait = waitMs > 0 ? sleep(waitMs) : Promise.resolve();
712
+ return wait.then(
713
+ () => {
714
+ let work: Promise<void>;
715
+ try {
716
+ lastStart = now();
717
+ work = worker(item);
718
+ } finally {
719
+ release();
720
+ }
721
+ return work;
722
+ },
723
+ (error: unknown) => {
724
+ release();
725
+ throw error;
726
+ },
727
+ );
728
+ }),
729
+ );
730
+ return Effect.forEach(pacedItems, runPaced, {
731
+ concurrency: Math.min(parallel, items.length),
732
+ discard: true,
733
+ });
674
734
  }
675
735
 
676
- export async function fetchTweetMedia(options: MediaFetchOptions = {}) {
677
- const now = options.now ?? Date.now;
678
- const startedAt = now();
679
- const sleep = options.sleep ?? defaultSleep;
680
- const fetchImpl = options.fetchImpl ?? fetch;
681
- const retryMax = Math.max(0, Math.floor(options.retryMax ?? 3));
682
- const parallel = Math.min(5, Math.max(1, Math.floor(options.parallel ?? 1)));
683
- const pacingMs = Math.max(0, Math.floor(options.pacingMs ?? 250));
684
- const videoPacingMs = Math.max(
685
- 0,
686
- Math.floor(options.videoPacingMs ?? pacingMs),
687
- );
688
- const maxBytes = Math.max(
689
- 0,
690
- Math.floor(options.maxBytes ?? DEFAULT_MAX_BYTES),
691
- );
692
- const userAgent =
693
- options.userAgent ??
694
- `birdclaw/${packageVersion ?? "0.0.0"} (https://github.com/steipete/birdclaw)`;
695
- const { mediaOriginalsDir } = getBirdclawPaths();
696
- mkdirSync(mediaOriginalsDir, { recursive: true });
697
-
698
- const { candidates, skipped_cached, would_fetch } = collect(
699
- options,
700
- mediaOriginalsDir,
701
- );
702
- const result: MediaFetchResult = {
703
- ok: true,
704
- fetched: 0,
705
- images_fetched: 0,
706
- videos_fetched: 0,
707
- gifs_fetched: 0,
708
- reused_from_archive: 0,
709
- skipped_cached,
710
- failed: 0,
711
- rate_limited: 0,
712
- bytes: 0,
713
- image_bytes: 0,
714
- video_bytes: 0,
715
- gif_bytes: 0,
716
- duration_ms: 0,
717
- failures: [],
718
- ...(options.dryRun ? { dry_run: true as const, would_fetch } : {}),
719
- };
736
+ export function fetchTweetMedia(options: MediaFetchOptions = {}) {
737
+ return runEffectPromise(fetchTweetMediaEffect(options));
738
+ }
720
739
 
721
- if (!options.dryRun) {
722
- const httpCandidates: Candidate[] = [];
723
- for (const item of candidates) {
724
- const reused = await reuseFromArchive(item, mediaOriginalsDir, maxBytes);
725
- if (reused) {
726
- applyFetched(result, reused);
727
- } else {
728
- httpCandidates.push(item);
729
- }
730
- }
731
- const fetchCandidate = async (item: Candidate) =>
732
- applyFetched(
733
- result,
734
- await fetchOne({
740
+ export function fetchTweetMediaEffect(options: MediaFetchOptions = {}) {
741
+ return Effect.gen(function* () {
742
+ const now = options.now ?? Date.now;
743
+ const startedAt = now();
744
+ const sleep = options.sleep ?? defaultSleep;
745
+ const fetchImpl = options.fetchImpl ?? fetch;
746
+ const retryMax = Math.max(0, Math.floor(options.retryMax ?? 3));
747
+ const parallel = Math.min(
748
+ 5,
749
+ Math.max(1, Math.floor(options.parallel ?? 1)),
750
+ );
751
+ const pacingMs = Math.max(0, Math.floor(options.pacingMs ?? 250));
752
+ const videoPacingMs = Math.max(
753
+ 0,
754
+ Math.floor(options.videoPacingMs ?? pacingMs),
755
+ );
756
+ const maxBytes = Math.max(
757
+ 0,
758
+ Math.floor(options.maxBytes ?? DEFAULT_MAX_BYTES),
759
+ );
760
+ const userAgent =
761
+ options.userAgent ??
762
+ `birdclaw/${packageVersion ?? "0.0.0"} (https://github.com/steipete/birdclaw)`;
763
+ const { mediaOriginalsDir } = getBirdclawPaths();
764
+ yield* tryMediaSync(() =>
765
+ mkdirSync(mediaOriginalsDir, { recursive: true }),
766
+ );
767
+
768
+ const { candidates, skipped_cached, would_fetch } = yield* tryMediaSync(
769
+ () => collect(options, mediaOriginalsDir),
770
+ );
771
+ const result: MediaFetchResult = {
772
+ ok: true,
773
+ fetched: 0,
774
+ images_fetched: 0,
775
+ videos_fetched: 0,
776
+ gifs_fetched: 0,
777
+ reused_from_archive: 0,
778
+ skipped_cached,
779
+ failed: 0,
780
+ rate_limited: 0,
781
+ bytes: 0,
782
+ image_bytes: 0,
783
+ video_bytes: 0,
784
+ gif_bytes: 0,
785
+ duration_ms: 0,
786
+ failures: [],
787
+ ...(options.dryRun ? { dry_run: true as const, would_fetch } : {}),
788
+ };
789
+
790
+ if (!options.dryRun) {
791
+ const httpCandidates: Candidate[] = [];
792
+ for (const item of candidates) {
793
+ const reused = yield* reuseFromArchiveEffect(
735
794
  item,
736
- fetchImpl,
737
- sleep,
738
- retryMax,
739
- userAgent,
795
+ mediaOriginalsDir,
740
796
  maxBytes,
741
- }),
797
+ );
798
+ if (reused) {
799
+ applyFetched(result, reused);
800
+ } else {
801
+ httpCandidates.push(item);
802
+ }
803
+ }
804
+ const fetchCandidate = (item: Candidate) =>
805
+ runEffectPromise(
806
+ fetchOneEffect({
807
+ item,
808
+ fetchImpl,
809
+ sleep,
810
+ retryMax,
811
+ userAgent,
812
+ maxBytes,
813
+ }).pipe(Effect.map((fetched) => applyFetched(result, fetched))),
814
+ );
815
+ yield* runGroupEffect(
816
+ httpCandidates.filter((item) => item.kind === "image"),
817
+ parallel,
818
+ pacingMs,
819
+ now,
820
+ sleep,
821
+ fetchCandidate,
742
822
  );
743
- await runGroup(
744
- httpCandidates.filter((item) => item.kind === "image"),
745
- parallel,
746
- pacingMs,
747
- now,
748
- sleep,
749
- fetchCandidate,
750
- );
751
- await runGroup(
752
- httpCandidates.filter((item) => item.kind !== "image"),
753
- 1,
754
- videoPacingMs,
755
- now,
756
- sleep,
757
- fetchCandidate,
758
- );
759
- }
823
+ yield* runGroupEffect(
824
+ httpCandidates.filter((item) => item.kind !== "image"),
825
+ 1,
826
+ videoPacingMs,
827
+ now,
828
+ sleep,
829
+ fetchCandidate,
830
+ );
831
+ }
760
832
 
761
- result.failed = result.failures.length;
762
- result.duration_ms = Math.max(0, Math.round(now() - startedAt));
763
- return result;
833
+ result.failed = result.failures.length;
834
+ result.duration_ms = Math.max(0, Math.round(now() - startedAt));
835
+ return result;
836
+ });
764
837
  }
765
838
 
766
839
  export function formatMediaFetchResult(result: MediaFetchResult) {